diff --git a/pyproject.toml b/pyproject.toml index 7fc94190b..a01d5fbd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,12 +29,13 @@ dependencies = [ "cryptography>=43.0.1,<45", "grpcio>=1.53.2,<1.69", "multidict<7.0,>=4.5", + "msgpack>=1,<1.2", "protobuf>=4.21.12,<5.29", "pydantic<3,>=2.10", "pydantic-settings<3,>=2.3", "pyyaml<7,>=6.0.1", "requests<2.33,>=2.32", - "simple-sqlite3-orm<0.6,>=0.5", + "simple-sqlite3-orm<0.7,>=0.6", "typing-extensions>=4.6.3", "urllib3<2.3,>=2.2.2", "uvicorn[standard]>=0.30,<0.35", diff --git a/requirements.txt b/requirements.txt index 3ad1b89e5..0f054df2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,13 @@ aiohttp>=3.10.11,<3.12 cryptography>=43.0.1,<45 grpcio>=1.53.2,<1.69 multidict<7.0,>=4.5 +msgpack>=1,<1.2 protobuf>=4.21.12,<5.29 pydantic<3,>=2.10 pydantic-settings<3,>=2.3 pyyaml<7,>=6.0.1 requests<2.33,>=2.32 -simple-sqlite3-orm<0.6,>=0.5 +simple-sqlite3-orm<0.7,>=0.6 typing-extensions>=4.6.3 urllib3<2.3,>=2.2.2 uvicorn[standard]>=0.30,<0.35 diff --git a/src/ota_metadata/file_table/__init__.py b/src/ota_metadata/file_table/__init__.py new file mode 100644 index 000000000..8cd0feea4 --- /dev/null +++ b/src/ota_metadata/file_table/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from ._orm import ( + FileTableDirORM, + FileTableNonRegularORM, + FileTableRegularORM, + FileTableRegularORMPool, +) +from ._table import ( + FileTableDirectories, + FileTableNonRegularFiles, + FileTableRegularFiles, +) +from ._types import FileEntryAttrs + +__all__ = [ + "FileTableNonRegularORM", + "FileTableRegularORM", + "FileTableDirORM", + "FileTableRegularORMPool", + "FileTableNonRegularFiles", + "FileTableRegularFiles", + "FileTableDirectories", + "FileEntryAttrs", +] diff --git a/src/ota_metadata/file_table/_orm.py b/src/ota_metadata/file_table/_orm.py new file mode 100644 index 000000000..88d67eee9 --- /dev/null +++ b/src/ota_metadata/file_table/_orm.py @@ -0,0 +1,53 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +from simple_sqlite3_orm import ORMBase, ORMThreadPoolBase + +from ._table import ( + FileTableDirectories, + FileTableNonRegularFiles, + FileTableRegularFiles, +) + +FT_REGULAR_TABLE_NAME = "ft_regular" +FT_NON_REGULAR_TABLE_NAME = "ft_non_regular" +FT_DIR_TABLE_NAME = "ft_dir" + + +class FileTableRegularORM(ORMBase[FileTableRegularFiles]): + + _orm_table_name = FT_REGULAR_TABLE_NAME + + +class FileTableRegularORMPool(ORMThreadPoolBase[FileTableRegularFiles]): + + _orm_table_name = FT_REGULAR_TABLE_NAME + + +class FileTableNonRegularORM(ORMBase[FileTableNonRegularFiles]): + + _orm_table_name = FT_NON_REGULAR_TABLE_NAME + + +class FileTableDirORM(ORMBase[FileTableDirectories]): + + _orm_table_name = FT_DIR_TABLE_NAME + + +class FileTableDirORMPool(ORMThreadPoolBase[FileTableDirectories]): + + _orm_table_name = FT_DIR_TABLE_NAME diff --git a/src/ota_metadata/file_table/_table.py b/src/ota_metadata/file_table/_table.py new file mode 100644 index 000000000..2a7a664f3 --- /dev/null +++ b/src/ota_metadata/file_table/_table.py @@ -0,0 +1,205 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import os +import shutil +import stat +from abc import abstractmethod +from pathlib import Path +from typing import Any, ClassVar, Literal, Optional + +from pydantic import BaseModel, SkipValidation +from simple_sqlite3_orm import ConstrainRepr, TableSpec, TypeAffinityRepr +from typing_extensions import Annotated + +from ota_metadata.file_table._types import EntryAttrsType +from otaclient_common.typing import StrOrPath + +CANONICAL_ROOT = "/" + + +class FileTableBase(BaseModel): + schema_ver: ClassVar[Literal[1]] = 1 + + path: Annotated[ + str, + TypeAffinityRepr(str), + ConstrainRepr("PRIMARY KEY"), + SkipValidation, + ] + + entry_attrs: Annotated[ + EntryAttrsType, + TypeAffinityRepr(bytes), + ConstrainRepr("NOT NULL"), + ] + """msgpacked basic attrs for this file entry. + + Ref: https://www.kernel.org/doc/html/latest/filesystems/ext4/inodes.html + + including the following fields from inode table: + 1. mode bits + 2. uid + 3. gid + 4. inode(when the file is hardlinked) + 5. xattrs + + See file_table._types for more details. + """ + + def set_xattr(self, _target: StrOrPath) -> None: + """Set the xattr of self onto the <_target>. + + NOTE: this method always don't follow symlink. + """ + if xattrs := self.entry_attrs.xattrs: + for k, v in xattrs.items(): + os.setxattr( + path=_target, + attribute=k, + value=v.encode(), + follow_symlinks=False, + ) + + def set_perm(self, _target: StrOrPath) -> None: + """Set the mode,uid,gid of self onto the <_target>.""" + entry_attrs = self.entry_attrs + # NOTE(20241213): chown will reset the sticky bit of the file!!! + # Remember to always put chown before chmod !!! + os.chown(_target, uid=entry_attrs.uid, gid=entry_attrs.gid) + os.chmod(_target, mode=entry_attrs.mode) + + def fpath_on_target(self, target_mnt: StrOrPath) -> Path: + """Return the fpath of self joined to .""" + _canonical_path = Path(self.path) + _target_on_mnt = Path(target_mnt) / _canonical_path.relative_to(CANONICAL_ROOT) + return _target_on_mnt + + @abstractmethod + def prepare_target(self, *args: Any, target_mnt: StrOrPath, **kwargs) -> None: + raise NotImplementedError + + +class FileTableRegularFiles(TableSpec, FileTableBase): + """DB table for regular file entries.""" + + digest: Annotated[ + bytes, + TypeAffinityRepr(bytes), + SkipValidation, + ] + + def prepare_target( + self, + _rs: StrOrPath, + *, + target_mnt: StrOrPath, + prepare_method: Literal["move", "hardlink", "copy"], + ) -> None: + _target_on_mnt = self.fpath_on_target(target_mnt=target_mnt) + + if prepare_method == "copy": + shutil.copy(_rs, _target_on_mnt) + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) + return + + if prepare_method == "hardlink": + # NOTE: os.link will make dst a hardlink to src. + os.link(_rs, _target_on_mnt) + # NOTE: although we actually don't need to set_perm and set_xattr everytime + # to file paths point to the same inode, for simplicity here we just + # do it everytime. + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) + return + + if prepare_method == "move": + shutil.move(str(_rs), _target_on_mnt) + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) + + +class FileTableNonRegularFiles(TableSpec, FileTableBase): + """DB table for non-regular file entries. + + This includes: + 1. symlink. + 2. chardev file. + + NOTE that support for chardev file is only for overlayfs' whiteout file, + so only device num as 0,0 will be allowed. + """ + + contents: Annotated[ + Optional[bytes], + TypeAffinityRepr(bytes), + SkipValidation, + ] = None + """The contents of the file. Currently only used by symlink.""" + + def set_perm(self, _target: StrOrPath) -> None: + """Set the mode,uid,gid of self onto the <_target>. + + NOTE: this method always don't follow symlink. + """ + entry_attrs = self.entry_attrs + + # NOTE(20241213): chown will reset the sticky bit of the file!!! + # Remember to always put chown before chmod !!! + os.chown( + _target, uid=entry_attrs.uid, gid=entry_attrs.gid, follow_symlinks=False + ) + # NOTE: changing mode of symlink is not needed and uneffective, and on some platform + # changing mode of symlink will even result in exception raised. + if not stat.S_ISLNK(entry_attrs.mode): + os.chmod(_target, mode=entry_attrs.mode) + + def prepare_target(self, *, target_mnt: StrOrPath) -> None: + _target_on_mnt = self.fpath_on_target(target_mnt=target_mnt) + + entry_attrs = self.entry_attrs + _mode = entry_attrs.mode + if stat.S_ISLNK(_mode): + assert ( + _symlink_target_raw := self.contents + ), f"invalid entry {self}, entry is a symlink but no link target is defined" + + _symlink_target = _symlink_target_raw.decode() + _target_on_mnt.symlink_to(_symlink_target) + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) + return + + if stat.S_ISCHR(_mode): + _device_num = os.makedev(0, 0) + os.mknod(_target_on_mnt, mode=_mode, device=_device_num) + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) + return + + raise ValueError(f"invalid entry {self}") + + +class FileTableDirectories(TableSpec, FileTableBase): + """DB table for directory entries.""" + + def prepare_target(self, *, target_mnt: StrOrPath) -> None: + _target_on_mnt = self.fpath_on_target(target_mnt=target_mnt) + _target_on_mnt.mkdir(exist_ok=True, parents=True) + self.set_perm(_target_on_mnt) + self.set_xattr(_target_on_mnt) diff --git a/src/ota_metadata/file_table/_types.py b/src/ota_metadata/file_table/_types.py new file mode 100644 index 000000000..7b261de88 --- /dev/null +++ b/src/ota_metadata/file_table/_types.py @@ -0,0 +1,63 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +from typing import Dict, NamedTuple, Optional + +from msgpack import Unpacker, packb +from pydantic import PlainSerializer, PlainValidator +from typing_extensions import Annotated + +FILE_ENTRY_MAX_SIZE = 1024**2 # 1MiB + + +class FileEntryAttrs(NamedTuple): + mode: int + uid: int + gid: int + size: Optional[int] = None + inode: Optional[int] = None + xattrs: Optional[Dict[str, str]] = None + + @classmethod + def _validator(cls, _in: bytes | FileEntryAttrs) -> FileEntryAttrs: + if isinstance(_in, FileEntryAttrs): + return _in + + _unpacker = Unpacker(max_buffer_size=FILE_ENTRY_MAX_SIZE) + _unpacker.feed(_in) # feed all the data into the internal buffer + + # get exactly one list from buffer. + # NOTE that msgpack only has two container types when unpacking: list and dict. + _obj = _unpacker.unpack() + if not isinstance(_obj, list): + raise TypeError(f"expect unpack to a list, get {type(_obj)=}") + return cls(*_obj) + + def _serializer(self: FileEntryAttrs) -> bytes: + try: + if _res := packb(self, buf_size=FILE_ENTRY_MAX_SIZE): + return _res + raise ValueError("nothing is packed") + except Exception as e: + raise ValueError(f"failed to pack {self}: {e!r}") from e + + +EntryAttrsType = Annotated[ + FileEntryAttrs, + PlainValidator(FileEntryAttrs._validator), + PlainSerializer(FileEntryAttrs._serializer), +] diff --git a/src/ota_metadata/legacy/rs_table.py b/src/ota_metadata/legacy/rs_table.py new file mode 100644 index 000000000..42fbe2ca3 --- /dev/null +++ b/src/ota_metadata/legacy/rs_table.py @@ -0,0 +1,100 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Resource table implementation for legacy OTA image.""" + + +from __future__ import annotations + +import random +from typing import Any, ClassVar, Generator, Literal, Optional + +from pydantic import SkipValidation +from simple_sqlite3_orm import ( + ConstrainRepr, + ORMBase, + ORMThreadPoolBase, + TableSpec, + TypeAffinityRepr, +) +from typing_extensions import Annotated, Self + +RSTABLE_NAME = "rs_table" + + +class ResourceTable(TableSpec): + schema_ver: ClassVar[Literal[1]] = 1 + + digest: Annotated[ + bytes, + TypeAffinityRepr(bytes), + ConstrainRepr("PRIMARY KEY"), + SkipValidation, + ] + """sha256 digest of the original file.""" + + path: Annotated[ + Optional[str], + TypeAffinityRepr(str), + SkipValidation, + ] = None + """NOTE: only for resource without zstd compression.""" + + original_size: Annotated[ + int, + TypeAffinityRepr(int), + ConstrainRepr("NOT NULL"), + SkipValidation, + ] + """The size of the plain uncompressed resource.""" + + compression_alg: Annotated[ + Optional[str], + TypeAffinityRepr(str), + SkipValidation, + ] = None + """The compression algorthim used to compressed the resource. + + NOTE that this field should be None if is not None. + """ + + def __eq__(self, other: Any | Self) -> bool: + return isinstance(other, self.__class__) and self.digest == other.digest + + def __hash__(self) -> int: + return hash(self.digest) + + +class RSTORM(ORMBase[ResourceTable]): + + _orm_table_name = RSTABLE_NAME + + def iter_all_with_shuffle(self, *, batch_size: int) -> Generator[ResourceTable]: + """Iter all entries with seek method by rowid, shuffle each batch before yield. + + NOTE: the target table must has rowid defined! + """ + _this_batch = [] + for _entry in self.orm_select_all_with_pagination(batch_size=batch_size): + _this_batch.append(_entry) + if len(_this_batch) >= batch_size: + random.shuffle(_this_batch) + yield from _this_batch + _this_batch = [] + random.shuffle(_this_batch) + yield from _this_batch + + +class RSTableORMThreadPool(ORMThreadPoolBase[ResourceTable]): + + _orm_table_name = RSTABLE_NAME diff --git a/tests/test_ota_metadata/test_file_table/__init__.py b/tests/test_ota_metadata/test_file_table/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_ota_metadata/test_file_table/test__orm.py b/tests/test_ota_metadata/test_file_table/test__orm.py new file mode 100644 index 000000000..4f63de070 --- /dev/null +++ b/tests/test_ota_metadata/test_file_table/test__orm.py @@ -0,0 +1,64 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import pytest + +from ota_metadata.file_table._types import FileEntryAttrs + + +@pytest.mark.parametrize( + "_in", + ( + # directory + ( + FileEntryAttrs( + mode=0o040755, + uid=1000, + gid=1000, + xattrs={ + "user.foo": "foo", + "user.bar": "bar", + }, + ) + ), + # normal file + ( + FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=12345, + inode=67890, + xattrs={ + "user.foo": "foo", + "user.bar": "bar", + }, + ) + ), + # symlink file + ( + FileEntryAttrs( + mode=0o120777, + uid=1000, + gid=1000, + ) + ), + ), +) +def test_validator_and_serializer(_in: FileEntryAttrs) -> None: + _serialized = _in._serializer() + assert _in._validator(_serialized) == _in diff --git a/tests/test_ota_metadata/test_file_table/test__table.py b/tests/test_ota_metadata/test_file_table/test__table.py new file mode 100644 index 000000000..1768c9c09 --- /dev/null +++ b/tests/test_ota_metadata/test_file_table/test__table.py @@ -0,0 +1,236 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import os +from hashlib import sha256 +from pathlib import Path + +import pytest + +from ota_metadata.file_table._table import ( + FileTableDirectories, + FileTableNonRegularFiles, + FileTableRegularFiles, +) +from ota_metadata.file_table._types import FileEntryAttrs + + +def _check_same_stat( + _fpath: Path, + _entry: FileEntryAttrs, + *, + is_symlink: bool = False, + check_size: bool = False, +): + _stat_from_fpath = os.stat(_fpath, follow_symlinks=False) + assert _stat_from_fpath.st_uid == _entry.uid + assert _stat_from_fpath.st_gid == _entry.gid + + # shortpath the checks not suitable for symlink + if is_symlink: + return + + assert _stat_from_fpath.st_mode == _entry.mode + if check_size: + assert _stat_from_fpath.st_size == _entry.size + + +def _check_hardlink(_fpath: Path, _src: Path): + assert _fpath.stat().st_ino == _src.stat().st_ino + + +def _check_xattr(_fpath: Path, _xattrs: dict[str, str]): + for k, v in _xattrs.items(): + assert os.getxattr(_fpath, k) == v.encode() + + +@pytest.mark.parametrize( + "_in", + ( + ( + FileTableDirectories( + path="/a/b/c/d/e", + entry_attrs=FileEntryAttrs( + mode=0o040755, + uid=1000, + gid=1000, + ), + ) + ), + ( + FileTableDirectories( + path="/α/β/γ", + entry_attrs=FileEntryAttrs( + mode=0o040755, + uid=1000, + gid=1000, + xattrs={ + "user.Ελληνικό": "αλφάβητο", + "user.bar": "bar", + }, + ), + ) + ), + ), +) +def test_dir(_in: FileTableDirectories, tmp_path: Path): + target = tmp_path + _in.prepare_target(target_mnt=target) + + _fpath_on_target = tmp_path / Path(_in.path).relative_to("/") + assert _fpath_on_target == _in.fpath_on_target(target_mnt=target) + _check_same_stat(_fpath_on_target, _in.entry_attrs) + if _xattrs := _in.entry_attrs.xattrs: + _check_xattr(_fpath_on_target, _xattrs) + + +TEST_FILE_SIZE = 64 # bytes + + +@pytest.mark.parametrize( + "file_contents, _in, prepare_method", + ( + # normal file, no hardlink, no xattrs + ( + file_contents := os.urandom(TEST_FILE_SIZE), + FileTableRegularFiles( + path="/a/b/c/d/e.bin", + digest=sha256(file_contents).digest(), + entry_attrs=FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=TEST_FILE_SIZE, + ), + ), + "copy", + ), + # normal file, hardlink, no xattrs + ( + file_contents := os.urandom(TEST_FILE_SIZE), + FileTableRegularFiles( + path="/a/b/c/d/e.bin", + digest=sha256(file_contents).digest(), + entry_attrs=FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=TEST_FILE_SIZE, + inode=67890, + xattrs={ + "user.Ελληνικό": "αλφάβητο", + "user.bar": "bar", + }, + ), + ), + "hardlink", + ), + # normal file, no hardlink, xattrs + ( + file_contents := os.urandom(TEST_FILE_SIZE), + FileTableRegularFiles( + path="/a/b/c/d/e.bin", + digest=sha256(file_contents).digest(), + entry_attrs=FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=TEST_FILE_SIZE, + xattrs={ + "user.Ελληνικό": "αλφάβητο", + "user.bar": "bar", + }, + ), + ), + "move", + ), + # normal file, hardlink, xattrs + ( + file_contents := os.urandom(TEST_FILE_SIZE), + FileTableRegularFiles( + path="/α/β/γ/ζ.bin", + digest=sha256(file_contents).digest(), + entry_attrs=FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=TEST_FILE_SIZE, + inode=67890, + xattrs={ + "user.Ελληνικό": "αλφάβητο", + "user.bar": "bar", + }, + ), + ), + "hardlink", + ), + ), +) +def test_regular_file( + file_contents: bytes, + _in: FileTableRegularFiles, + prepare_method, + tmp_path: Path, +): + src = tmp_path / "_test_file_contest" + src.write_bytes(file_contents) + _src_inode = src.stat().st_ino + + target = tmp_path + _fpath_on_target = tmp_path / Path(_in.path).relative_to("/") + # NOTE that prepare_target doesn't prepare the parents of the entry, + # we need to do it by ourselves. + _fpath_on_target.parent.mkdir(exist_ok=True, parents=True) + + _in.prepare_target(src, target_mnt=target, prepare_method=prepare_method) + + _check_same_stat(_fpath_on_target, _in.entry_attrs, check_size=True) + + if prepare_method == "hardlink": + _check_hardlink(_fpath_on_target, src) + elif prepare_method == "move": + assert not src.exists() and _src_inode == _fpath_on_target.stat().st_ino + + if xattrs := _in.entry_attrs.xattrs: + _check_xattr(_fpath_on_target, xattrs) + + +@pytest.mark.parametrize( + "_in", + ( + ( + FileTableNonRegularFiles( + path="/a/b/c/d/symlink", + contents="/α/β/γ/ζ.bin".encode(), + entry_attrs=FileEntryAttrs( + mode=0o120777, + uid=1000, + gid=1000, + ), + ) + ), + ), +) +def test_non_regular_file(_in: FileTableNonRegularFiles, tmp_path: Path): + _fpath_on_target = tmp_path / Path(_in.path).relative_to("/") + # NOTE: symlink preapre_target doesn't prepare parent, we need to do it by ourselves. + _fpath_on_target.parent.mkdir(exist_ok=True, parents=True) + + _in.prepare_target(target_mnt=tmp_path) + + _check_same_stat(_fpath_on_target, _in.entry_attrs, is_symlink=True) + assert _in.contents and os.readlink(_fpath_on_target) == _in.contents.decode() diff --git a/tests/test_ota_metadata/test_file_table/test__types.py b/tests/test_ota_metadata/test_file_table/test__types.py new file mode 100644 index 000000000..4f63de070 --- /dev/null +++ b/tests/test_ota_metadata/test_file_table/test__types.py @@ -0,0 +1,64 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import pytest + +from ota_metadata.file_table._types import FileEntryAttrs + + +@pytest.mark.parametrize( + "_in", + ( + # directory + ( + FileEntryAttrs( + mode=0o040755, + uid=1000, + gid=1000, + xattrs={ + "user.foo": "foo", + "user.bar": "bar", + }, + ) + ), + # normal file + ( + FileEntryAttrs( + mode=0o100644, + uid=1000, + gid=1000, + size=12345, + inode=67890, + xattrs={ + "user.foo": "foo", + "user.bar": "bar", + }, + ) + ), + # symlink file + ( + FileEntryAttrs( + mode=0o120777, + uid=1000, + gid=1000, + ) + ), + ), +) +def test_validator_and_serializer(_in: FileEntryAttrs) -> None: + _serialized = _in._serializer() + assert _in._validator(_serialized) == _in