#!/usr/bin/env python3 """Build and verify the deterministic xattr, ACL, and metabox G4 fixtures.""" from __future__ import annotations import argparse from dataclasses import dataclass import hashlib import json import os from pathlib import Path import shutil import stat import struct import subprocess SUPER = 1024 MAGIC = 0xE0F5E1E2 FEATURE_COMPAT_SB_CHKSUM = 0x00000001 FEATURE_COMPAT_SHARED_EA_IN_METABOX = 0x00000008 FEATURE_COMPAT_PLAIN_XATTR_PFX = 0x00000010 FEATURE_COMPAT_ISHARE_XATTRS = 0x00000020 FEATURE_INCOMPAT_FRAGMENTS = 0x00000020 FEATURE_INCOMPAT_XATTR_PREFIXES = 0x00000040 FEATURE_INCOMPAT_METABOX = 0x00000100 XATTR_INDEX_USER = 1 XATTR_INDEX_ACL_ACCESS = 2 XATTR_INDEX_ACL_DEFAULT = 3 XATTR_INDEX_TRUSTED = 4 XATTR_INDEX_SECURITY = 6 XATTR_LONG_PREFIX = 0x80 METABOX_NID_BIT = 1 << 63 LAYOUT_FLAT_PLAIN = 0 LAYOUT_COMPRESSED_FULL = 1 LAYOUT_FLAT_INLINE = 2 LAYOUT_COMPRESSED_COMPACT = 3 ACL_VERSION = 2 ACL_USER_OBJ = 0x01 ACL_USER = 0x02 ACL_GROUP_OBJ = 0x04 ACL_GROUP = 0x08 ACL_MASK = 0x10 ACL_OTHER = 0x20 ACL_UNDEFINED_ID = 0xFFFFFFFF BLOCK_SIZE = 4096 METABOX_SIZE = 32768 METABOX_INODE_A = 16 METABOX_INODE_B = 32 METABOX_PREFIX_OFFSET = 8192 METABOX_XATTR_BASE = 4096 CRC32C_POLYNOMIAL = 0x82F63B78 class FixtureError(RuntimeError): pass @dataclass(frozen=True) class Inode: nid: int offset: int inode_format: int inode_size: int xattr_size: int layout: int mode: int size: int start_block: int @dataclass(frozen=True) class DirectoryEntry: name: bytes nid: int offset: int @dataclass(frozen=True) class XattrEntry: offset: int size: int name_index: int name: bytes value: bytes def align(value: int, alignment: int) -> int: return (value + alignment - 1) & -alignment def sha256_bytes(data: bytes | bytearray) -> str: return hashlib.sha256(data).hexdigest() def sha256_path(path: Path) -> str: return sha256_bytes(path.read_bytes()) def crc32c(data: bytes | bytearray) -> int: checksum = 0xFFFFFFFF for byte in data: checksum ^= byte for _ in range(8): checksum = (checksum >> 1) ^ ( CRC32C_POLYNOMIAL if checksum & 1 else 0 ) return checksum & 0xFFFFFFFF class ErofsImage: def __init__(self, data: bytes | bytearray, source: Path) -> None: self.data = bytearray(data) self.source = source self.validate_superblock() @classmethod def load(cls, path: Path) -> "ErofsImage": return cls(path.read_bytes(), path) def clone(self) -> "ErofsImage": return ErofsImage(self.data, self.source) def u16(self, offset: int) -> int: return struct.unpack_from(" int: return struct.unpack_from(" int: return struct.unpack_from(" None: struct.pack_into(" None: struct.pack_into(" None: struct.pack_into(" int: return self.data[SUPER + 12] @property def block_size(self) -> int: return 1 << self.block_bits @property def feature_compat(self) -> int: return self.u32(SUPER + 8) @property def feature_incompat(self) -> int: return self.u32(SUPER + 80) @property def blocks(self) -> int: return self.u32(SUPER + 36) @property def declared_size(self) -> int: return self.blocks << self.block_bits @property def root_nid(self) -> int: return self.u16(SUPER + 14) @property def meta_offset(self) -> int: return self.u32(SUPER + 40) << self.block_bits @property def packed_nid(self) -> int: return self.u64(SUPER + 96) @property def checksum_end(self) -> int: return SUPER + self.block_size - SUPER def calculated_checksum(self) -> int: window = bytearray(self.data[SUPER : self.checksum_end]) struct.pack_into(" None: if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM: raise FixtureError(f"{self.source}: checksum feature is required") self.put_u32(SUPER + 4, 0) self.put_u32(SUPER + 4, self.calculated_checksum()) if self.u32(SUPER + 4) != self.calculated_checksum(): raise AssertionError("updated checksum does not verify") def validate_superblock(self) -> None: if len(self.data) < SUPER + 144: raise FixtureError(f"{self.source}: image is shorter than 1168 bytes") if self.u32(SUPER) != MAGIC: raise FixtureError(f"{self.source}: bad magic") if not 9 <= self.block_bits <= 16: raise FixtureError(f"{self.source}: invalid block bits") if self.blocks == 0 or self.declared_size > len(self.data): raise FixtureError(f"{self.source}: invalid declared size") if ( self.feature_compat & FEATURE_COMPAT_SB_CHKSUM and self.u32(SUPER + 4) != self.calculated_checksum() ): raise FixtureError(f"{self.source}: bad checksum") def inode(self, nid: int) -> Inode: offset = self.meta_offset + (nid << 5) if offset > self.declared_size - 32: raise FixtureError(f"{self.source}: nid {nid} is outside the image") inode_format = self.u16(offset) inode_size = 64 if inode_format & 1 else 32 count = self.u16(offset + 2) xattr_size = 0 if count == 0 else 12 + 4 * (count - 1) size = self.u64(offset + 8) if inode_size == 64 else self.u32(offset + 8) return Inode( nid=nid, offset=offset, inode_format=inode_format, inode_size=inode_size, xattr_size=xattr_size, layout=(inode_format >> 1) & 7, mode=self.u16(offset + 4), size=size, start_block=self.u32(offset + 16), ) def inode_data(self, inode: Inode) -> bytes: if inode.layout == LAYOUT_FLAT_PLAIN: start = inode.start_block << self.block_bits end = start + inode.size if end > self.declared_size: raise FixtureError("plain inode data exceeds the declared image") return bytes(self.data[start:end]) if inode.layout != LAYOUT_FLAT_INLINE: raise FixtureError(f"inode {inode.nid}: unsupported host layout {inode.layout}") full_size = inode.size // self.block_size * self.block_size if inode.size and inode.size % self.block_size == 0: full_size -= self.block_size full_start = inode.start_block << self.block_bits inline_start = inode.offset + inode.inode_size + inode.xattr_size full = self.data[full_start : full_start + full_size] tail = self.data[inline_start : inline_start + inode.size - full_size] if len(full) + len(tail) != inode.size: raise FixtureError("inline inode data is truncated") return bytes(full + tail) def directory_entries(self, inode: Inode) -> list[DirectoryEntry]: data = self.inode_data(inode) entries: list[DirectoryEntry] = [] data_base = ( inode.start_block << self.block_bits if inode.layout == LAYOUT_FLAT_PLAIN else inode.offset + inode.inode_size + inode.xattr_size ) for block_start in range(0, len(data), self.block_size): block = data[block_start : block_start + self.block_size] if len(block) < 12: raise FixtureError("short directory block") first_name = struct.unpack_from(" len(block): raise FixtureError("invalid directory first-name offset") count = first_name // 12 for index in range(count): item = index * 12 name_start = struct.unpack_from(" tuple[DirectoryEntry, Inode]: encoded = name.removeprefix("/").encode("ascii") root = self.inode(self.root_nid) for entry in self.directory_entries(root): if entry.name == encoded: return entry, self.inode(entry.nid) raise FixtureError(f"{self.source}: root entry not found: {name}") def inline_xattrs(self, inode: Inode) -> tuple[int, list[XattrEntry]]: if inode.xattr_size < 12: raise FixtureError(f"inode {inode.nid} has no xattr header") body = inode.offset + inode.inode_size shared_count = self.data[body + 4] cursor = body + 12 + shared_count * 4 end = body + inode.xattr_size if cursor > end: raise FixtureError("shared xattr array exceeds inode body") entries = [] while cursor < end: if cursor > end - 4: raise FixtureError("truncated inline xattr header") name_len = self.data[cursor] name_index = self.data[cursor + 1] value_len = self.u16(cursor + 2) size = align(4 + name_len + value_len, 4) if size > end - cursor: raise FixtureError("inline xattr entry exceeds inode body") entries.append( XattrEntry( offset=cursor, size=size, name_index=name_index, name=bytes(self.data[cursor + 4 : cursor + 4 + name_len]), value=bytes( self.data[ cursor + 4 + name_len : cursor + 4 + name_len + value_len ] ), ) ) cursor += size return shared_count, entries def shared_ids(self, inode: Inode) -> list[int]: body = inode.offset + inode.inode_size count = self.data[body + 4] return [self.u32(body + 12 + index * 4) for index in range(count)] def shared_xattr(self, shared_id: int) -> XattrEntry: offset = (self.u32(SUPER + 44) << self.block_bits) + shared_id * 4 if offset > self.declared_size - 4: raise FixtureError(f"shared id {shared_id} starts outside the image") name_len = self.data[offset] value_len = self.u16(offset + 2) size = align(4 + name_len + value_len, 4) if size > self.declared_size - offset: raise FixtureError(f"shared id {shared_id} exceeds the image") return XattrEntry( offset=offset, size=size, name_index=self.data[offset + 1], name=bytes(self.data[offset + 4 : offset + 4 + name_len]), value=bytes( self.data[offset + 4 + name_len : offset + 4 + name_len + value_len] ), ) def find_xattr(self, path: str, name: bytes) -> tuple[str, XattrEntry, int | None]: _, inode = self.resolve_root(path) _, inline = self.inline_xattrs(inode) for entry in inline: if entry.name == name: return "inline", entry, None for index, shared_id in enumerate(self.shared_ids(inode)): entry = self.shared_xattr(shared_id) if entry.name == name: return "shared", entry, index raise FixtureError(f"{path}: xattr {name!r} not found") def prefix_records(self) -> list[tuple[int, int, bytes]]: count = self.data[SUPER + 91] if count == 0: return [] if self.feature_compat & FEATURE_COMPAT_PLAIN_XATTR_PFX: backing = bytes(self.data[: self.declared_size]) else: if self.packed_nid == 0: raise FixtureError("prefix table has no packed backing") backing = self.inode_data(self.inode(self.packed_nid)) cursor = self.u32(SUPER + 92) << 2 records = [] for _ in range(count): cursor = align(cursor, 4) if cursor > len(backing) - 3: raise FixtureError("prefix record header exceeds backing") length = struct.unpack_from(" len(backing): raise FixtureError("invalid prefix record") records.append((cursor, backing[cursor + 2], backing[cursor + 3 : cursor + 2 + length])) cursor += 2 + length return records def save(self, path: Path) -> None: path.write_bytes(self.data) def xattr_entry(name_index: int, name: bytes, value: bytes) -> bytes: raw = struct.pack(" bytes: return struct.pack(" bytes: entries = [(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID)] entries.extend((ACL_USER, 5, identifier) for identifier in named_users) entries.extend( [ (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID), (ACL_MASK, 5, ACL_UNDEFINED_ID), (ACL_OTHER, 1, ACL_UNDEFINED_ID), ] ) return acl_value(entries) def prefix_record(base_index: int, infix: bytes) -> bytes: payload = bytes([base_index]) + infix return struct.pack(" int: start = align(offset, 4) blob[start : start + len(payload)] = payload return start def synthetic_inode(content: bytes, shared_ids: list[int], item_value: bytes) -> bytes: header = bytearray(12 + 4 * len(shared_ids)) header[4] = len(shared_ids) for index, shared_id in enumerate(shared_ids): struct.pack_into(" tuple[bytes, dict[str, object]]: payload = bytearray(METABOX_SIZE) shared_offset = METABOX_XATTR_BASE shared_records = [] for name_index, name, value in ( (XATTR_INDEX_USER, b"metaboxshared", b"nonzero-base"), (XATTR_LONG_PREFIX | 0, b"setting", b"long-prefix-value"), (XATTR_INDEX_USER, b"shared-prefix-key", b"shared-value"), ): raw = xattr_entry(name_index, name, value) start = add_aligned(payload, shared_offset, raw) shared_id = (start - METABOX_XATTR_BASE) // 4 shared_records.append((shared_id, start, raw)) shared_offset = start + len(raw) prefix_cursor = METABOX_PREFIX_OFFSET prefix_fields = [] for base_index, infix in ( (XATTR_INDEX_USER, b"repo22.application.component."), (XATTR_INDEX_TRUSTED, b"repo22.trusted.deep."), ): raw = prefix_record(base_index, infix) start = add_aligned(payload, prefix_cursor, raw) prefix_fields.append((start, base_index, infix)) prefix_cursor = start + len(raw) inode_a = synthetic_inode( b"metabox-file-a\n", [shared_records[0][0], shared_records[1][0], shared_records[2][0]], b"value-000", ) inode_b = synthetic_inode( b"metabox-file-b\n", [shared_records[0][0]], b"value-001", ) offset_a = METABOX_INODE_A << 5 offset_b = METABOX_INODE_B << 5 if offset_a + len(inode_a) >= offset_b: raise AssertionError("synthetic metabox inodes overlap") payload[offset_a : offset_a + len(inode_a)] = inode_a payload[offset_b : offset_b + len(inode_b)] = inode_b manifest = { "size": len(payload), "sha256": sha256_bytes(payload), "inode_a": {"nid": METABOX_INODE_A, "offset": offset_a}, "inode_b": {"nid": METABOX_INODE_B, "offset": offset_b}, "xattr_base": METABOX_XATTR_BASE, "shared": [ { "id": shared_id, "offset": offset, "sha256": sha256_bytes(raw), } for shared_id, offset, raw in shared_records ], "prefix_start": METABOX_PREFIX_OFFSET // 4, "prefixes": [ {"offset": offset, "base_index": base_index, "infix": infix.decode("ascii")} for offset, base_index, infix in prefix_fields ], } return bytes(payload), manifest def write_file(path: Path, data: bytes = b"") -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) def set_user_xattr(path: Path, name: str, value: bytes) -> None: os.setxattr(path, f"user.{name}", value) def normalize_times(root: Path) -> None: for path in sorted(root.rglob("*"), reverse=True): os.utime(path, (0, 0), follow_symlinks=False) os.utime(root, (0, 0), follow_symlinks=False) def make_sources(output: Path) -> dict[str, object]: source_root = output / "source" basic = source_root / "basic" carrier = source_root / "carrier" basic.mkdir(parents=True) carrier.mkdir(parents=True) metabox_payload, metabox_manifest = make_metabox_payload() basic_files: dict[str, dict[str, bytes]] = { "inline-user": { "comment": b"inline-user-value\x00tail", "special.chars": b"special-value", }, "inline-multi": { "attr1": b"one", "attr2": b"two\x00binary", "attr3": bytes(range(32)), }, "inline-trusted": {"admin": b"trusted-inline-value"}, "inline-security": { "capability": bytes.fromhex("0100000200000000aabbccdd"), "selinux": b"system_u:object_r:repo22_t:s0\x00", }, "corrupt-inline": {"bad": b"bad-format-target"}, "user-subset": { "comment": b"subset-comment", "author": b"repo22", "checksum": b"sha256:0123456789abcdef", "com.repo22.app.setting": b"enabled", }, } for name, attrs in basic_files.items(): path = basic / name write_file(path, f"{name}\n".encode("ascii")) for xattr_name, value in attrs.items(): set_user_xattr(path, xattr_name, value) for name in ("shared-a", "shared-b", "shared-multi"): path = basic / name write_file(path, f"{name}\n".encode("ascii")) set_user_xattr(path, "shared_key", b"shared-value\x00exact") set_user_xattr(path, "shared_comment", b"shared-comment") set_user_xattr(path, "shared_binary", bytes(range(16))) set_user_xattr(basic / "shared-a", "local", b"in-bounds-local") for name in ("trusted-shared-a", "trusted-shared-b", "trusted-shared-c"): path = basic / name write_file(path, f"{name}\n".encode("ascii")) set_user_xattr(path, "config", b"trusted-shared-value") for name in ("security-shared-a", "security-shared-b", "security-shared-c"): path = basic / name write_file(path, f"{name}\n".encode("ascii")) set_user_xattr(path, "selinux", b"system_u:object_r:shared_repo22_t:s0\x00") for index in range(4): path = basic / f"prefix-user-{index}" write_file(path, f"prefix user {index}\n".encode("ascii")) set_user_xattr( path, "repo22.application.component.setting", f"prefix-value-{index}".encode("ascii"), ) for index in range(3): path = basic / f"prefix-trusted-{index}" write_file(path, f"prefix trusted {index}\n".encode("ascii")) set_user_xattr( path, "repo22.trusted.deep.setting", f"trusted-prefix-value-{index}".encode("ascii"), ) acl_values = { "acl-unordered": canonical_acl(3002, 2002), "acl-header": struct.pack(" dict[str, object]: entries = [] for path in sorted([root, *root.rglob("*")]): info = path.lstat() relative = path.relative_to(root).as_posix() or "." item: dict[str, object] = { "path": relative, "mode": stat.S_IFMT(info.st_mode) | stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid, "size": info.st_size, } if path.is_file(): item["content_sha256"] = sha256_path(path) names = sorted(os.listxattr(path, follow_symlinks=False)) item["xattrs"] = { name: os.getxattr(path, name, follow_symlinks=False).hex() for name in names } entries.append(item) canonical = json.dumps(entries, separators=(",", ":"), sort_keys=True).encode("ascii") return {"sha256": sha256_bytes(canonical), "entries": entries} def tool_version() -> str: result = subprocess.run( ["mkfs.erofs", "-V"], check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) version = result.stdout.strip() if "erofs-utils) 1.8.6" not in version: raise FixtureError(f"mkfs.erofs 1.8.6 is required, got: {version}") return version def run_mkfs(source: Path, image: Path, uuid: str, *extra: str) -> list[str]: command = [ "mkfs.erofs", "-d0", "-T0", "--all-time", "--all-root", "--workers=1", "--sort=path", "-U", uuid, "--xattr-prefix=user.repo22.application.component.", "--xattr-prefix=user.repo22.trusted.deep.", *extra, str(image), str(source), ] subprocess.run(command, check=True) return command class TransformLog: def __init__(self) -> None: self.records: list[dict[str, object]] = [] def bytes(self, image: ErofsImage, field: str, offset: int, after: bytes) -> None: before = bytes(image.data[offset : offset + len(after)]) if len(before) != len(after): raise FixtureError(f"{field}: patch exceeds image") image.data[offset : offset + len(after)] = after self.records.append( { "field": field, "offset": offset, "size": len(after), "before_hex": before.hex(), "after_hex": after.hex(), } ) def u8(self, image: ErofsImage, field: str, offset: int, value: int) -> None: self.bytes(image, field, offset, bytes([value])) def u16(self, image: ErofsImage, field: str, offset: int, value: int) -> None: self.bytes(image, field, offset, struct.pack(" None: self.bytes(image, field, offset, struct.pack(" None: self.bytes(image, field, offset, struct.pack(" int: for prefix_id, (_, _, record_infix) in enumerate(image.prefix_records()): if record_infix == infix: return prefix_id raise FixtureError(f"prefix not found: {infix!r}") def patch_namespace_entries(image: ErofsImage, log: TransformLog) -> None: for path, name, expected_storage, new_index in ( ("/inline-trusted", b"admin", "inline", XATTR_INDEX_TRUSTED), ("/inline-security", b"capability", "inline", XATTR_INDEX_SECURITY), ("/inline-security", b"selinux", "inline", XATTR_INDEX_SECURITY), ("/trusted-shared-a", b"config", "shared", XATTR_INDEX_TRUSTED), ("/security-shared-a", b"selinux", "shared", XATTR_INDEX_SECURITY), ): storage, entry, _ = image.find_xattr(path, name) if storage != expected_storage or entry.name_index != XATTR_INDEX_USER: raise FixtureError(f"{path}:{name!r} has unexpected storage/index") log.u8(image, f"{path}:{name.decode()}.e_name_index", entry.offset + 1, new_index) trusted_prefix_id = find_prefix_id(image, b"repo22.trusted.deep.") packed = image.inode(image.packed_nid) packed_data = image.inode_data(packed) record_offset = image.prefix_records()[trusted_prefix_id][0] if packed.layout != LAYOUT_FLAT_INLINE: raise FixtureError("expected inline packed prefix carrier") physical = packed.offset + packed.inode_size + packed.xattr_size + record_offset + 2 if packed_data[record_offset + 2] != XATTR_INDEX_USER: raise FixtureError("trusted prefix base is not the user placeholder") log.u8(image, "packed_prefix.trusted.base_index", physical, XATTR_INDEX_TRUSTED) def replace_acl_entry(image: ErofsImage, path: str, value: bytes, log: TransformLog) -> None: _, inode = image.resolve_root(path) shared_count, entries = image.inline_xattrs(inode) placeholders = [entry for entry in entries if entry.name == b"acl_access_placeholder"] if shared_count != 0 or len(placeholders) != 1: raise FixtureError(f"{path}: ACL placeholder is not one inline xattr") body = bytearray(12) for entry in entries: if entry.name == b"acl_access_placeholder": body.extend(xattr_entry(XATTR_INDEX_ACL_ACCESS, b"", value)) else: body.extend(xattr_entry(entry.name_index, entry.name, entry.value)) body.extend(bytes(align(len(body), 4) - len(body))) if len(body) > inode.xattr_size: raise FixtureError(f"{path}: rebuilt ACL body grew") padded = body + bytes(inode.xattr_size - len(body)) log.bytes(image, f"{path}.xattr_body", inode.offset + inode.inode_size, padded) icount = 1 + (len(body) - 12) // 4 log.u16(image, f"{path}.i_xattr_icount", inode.offset + 2, icount) def patch_acls(image: ErofsImage, log: TransformLog) -> None: acl_values = { "/acl-unordered": canonical_acl(3002, 2002), "/acl-header": struct.pack(" None: image.update_checksum() image.validate_superblock() image.save(path) def transformed_basic(base: Path) -> tuple[ErofsImage, TransformLog]: image = ErofsImage.load(base) log = TransformLog() patch_namespace_entries(image, log) patch_acls(image, log) image.update_checksum() return image, log def add_metabox_fields( image: ErofsImage, log: TransformLog, carrier_nid: int, clear_packed: bool, ) -> None: if image.u32(SUPER + 40) != 0: raise FixtureError("metabox conversion expects metadata in block zero") metadata_copy = bytes(image.data[: image.block_size]) relocated_offset = align(len(image.data), image.block_size) image.data.extend(bytes(relocated_offset - len(image.data))) image.data.extend(bytes(image.block_size)) image.data[relocated_offset : relocated_offset + image.block_size] = metadata_copy log.u32(image, "super.blocks_lo", SUPER + 36, len(image.data) // image.block_size) log.u32(image, "super.meta_blkaddr", SUPER + 40, relocated_offset // image.block_size) log.u32( image, "super.feature_compat", SUPER + 8, image.feature_compat | FEATURE_COMPAT_SHARED_EA_IN_METABOX, ) log.u8(image, "super.sb_extslots", SUPER + 13, 1) log.u32( image, "super.feature_incompat", SUPER + 80, image.feature_incompat | FEATURE_INCOMPAT_METABOX | FEATURE_INCOMPAT_XATTR_PREFIXES, ) log.u32(image, "super.xattr_blkaddr", SUPER + 44, METABOX_XATTR_BASE // BLOCK_SIZE) log.u8(image, "super.xattr_prefix_count", SUPER + 91, 2) log.u32(image, "super.xattr_prefix_start", SUPER + 92, METABOX_PREFIX_OFFSET // 4) log.u64(image, "super.metabox_nid", SUPER + 128, carrier_nid) if clear_packed: log.u64(image, "super.packed_nid", SUPER + 96, 0) for path, nid in (("/metabox-a", METABOX_INODE_A), ("/metabox-b", METABOX_INODE_B)): entry, _ = image.resolve_root(path) log.u64(image, f"{path}.dirent_nid", entry.offset, METABOX_NID_BIT | nid) def make_primary_prefix(image: ErofsImage, log: TransformLog) -> None: packed_data = image.inode_data(image.inode(image.packed_nid)) start = align(len(image.data), image.block_size) if len(image.data) < start: image.data.extend(bytes(start - len(image.data))) image.data.extend(packed_data) image.data.extend(bytes(align(len(image.data), image.block_size) - len(image.data))) new_blocks = len(image.data) // image.block_size log.u32(image, "super.blocks_lo", SUPER + 36, new_blocks) log.u32( image, "super.feature_compat", SUPER + 8, image.feature_compat | FEATURE_COMPAT_PLAIN_XATTR_PFX, ) log.u32(image, "super.xattr_prefix_start", SUPER + 92, start // 4) log.u64(image, "super.packed_nid", SUPER + 96, 0) def save_variant( output: Path, name: str, image: ErofsImage, log: TransformLog, transforms: dict[str, object], base_name: str, semantics: dict[str, object] | None = None, ) -> None: path = output / "images" / name checksum_and_save(image, path) transforms[name] = { "base": base_name, "image_sha256": sha256_path(path), "provider_size": path.stat().st_size, "declared_size": image.declared_size, "fields": log.records, "semantics": semantics or {}, } def make_variants(output: Path, base_commands: dict[str, list[str]]) -> dict[str, object]: bases = output / "bases" images = output / "images" images.mkdir() transforms: dict[str, object] = {} basic, basic_log = transformed_basic(bases / "basic-base.erofs") save_variant(output, "basic.erofs", basic.clone(), basic_log, transforms, "basic-base.erofs") primary = basic.clone() primary_log = TransformLog() make_primary_prefix(primary, primary_log) save_variant(output, "prefix-primary.erofs", primary, primary_log, transforms, "basic.erofs") metabox_plain = ErofsImage.load(bases / "carrier-plain-base.erofs") metabox_plain_log = TransformLog() _, carrier_inode = metabox_plain.resolve_root("/metabox-carrier.bin") if carrier_inode.layout != LAYOUT_FLAT_PLAIN or carrier_inode.size != METABOX_SIZE: raise FixtureError("plain metabox carrier has the wrong layout or size") add_metabox_fields(metabox_plain, metabox_plain_log, carrier_inode.nid, True) save_variant( output, "metabox-plain.erofs", metabox_plain, metabox_plain_log, transforms, "carrier-plain-base.erofs", {"carrier_nid": carrier_inode.nid, "carrier_layout": carrier_inode.layout}, ) compressed = ErofsImage.load(bases / "carrier-compressed-base.erofs") compressed_log = TransformLog() _, compressed_carrier = compressed.resolve_root("/metabox-carrier.bin") if compressed_carrier.layout not in (LAYOUT_COMPRESSED_FULL, LAYOUT_COMPRESSED_COMPACT): raise FixtureError("compressed metabox carrier is not compressed") add_metabox_fields(compressed, compressed_log, compressed_carrier.nid, True) save_variant( output, "metabox-compressed.erofs", compressed, compressed_log, transforms, "carrier-compressed-base.erofs", {"carrier_nid": compressed_carrier.nid, "carrier_layout": compressed_carrier.layout}, ) fragment = ErofsImage.load(bases / "carrier-fragment-base.erofs") fragment_log = TransformLog() _, fragment_carrier = fragment.resolve_root("/metabox-carrier.bin") if fragment_carrier.layout not in (LAYOUT_COMPRESSED_FULL, LAYOUT_COMPRESSED_COMPACT): raise FixtureError("fragment metabox carrier is not compressed") add_metabox_fields(fragment, fragment_log, fragment_carrier.nid, False) _, fragment_carrier = fragment.resolve_root("/metabox-carrier.bin") header_offset = align( fragment_carrier.offset + fragment_carrier.inode_size + fragment_carrier.xattr_size, 8, ) fragment_header = fragment.u64(header_offset) if not fragment_header & METABOX_NID_BIT: raise FixtureError("metabox carrier is not a whole-file fragment") fragment_offset = fragment_header ^ METABOX_NID_BIT packed_inode = fragment.inode(fragment.packed_nid) if fragment_offset + fragment_carrier.size > packed_inode.size: raise FixtureError("positive fragment range exceeds packed inode") fragment_semantics = { "carrier_nid": fragment_carrier.nid, "carrier_layout": fragment_carrier.layout, "fragment_header_offset": header_offset, "fragment_header": fragment_header, "fragment_offset": fragment_offset, "carrier_size": fragment_carrier.size, "packed_nid": fragment.packed_nid, "packed_size": packed_inode.size, } save_variant( output, "metabox-fragment.erofs", fragment, fragment_log, transforms, "carrier-fragment-base.erofs", fragment_semantics, ) bad_inline = basic.clone() bad_inline_log = TransformLog() storage, entry, _ = bad_inline.find_xattr("/corrupt-inline", b"bad") if storage != "inline": raise FixtureError("corrupt-inline target is not inline") bad_inline_log.u16(bad_inline, "/corrupt-inline.e_value_size", entry.offset + 2, 0xFFFF) save_variant(output, "bad-inline-entry.erofs", bad_inline, bad_inline_log, transforms, "basic.erofs") bad_shared = basic.clone() bad_shared_log = TransformLog() storage, shared_entry, _ = bad_shared.find_xattr("/shared-a", b"shared_key") if storage != "shared": raise FixtureError("shared corruption target is not shared") bad_shared_log.u16(bad_shared, "shared_key.e_value_size", shared_entry.offset + 2, 0xFFFF) save_variant(output, "bad-shared-entry.erofs", bad_shared, bad_shared_log, transforms, "basic.erofs") shared_oob = basic.clone() shared_oob_log = TransformLog() _, shared_inode = shared_oob.resolve_root("/shared-a") target_id = None target_array_index = None for array_index, shared_id in enumerate(shared_oob.shared_ids(shared_inode)): if shared_oob.shared_xattr(shared_id).name == b"shared_key": target_id = shared_id target_array_index = array_index break if target_id is None or target_array_index is None: raise FixtureError("shared OOB target ID not found") declared_end = shared_oob.declared_size sentinel = xattr_entry(XATTR_INDEX_USER, b"shared_key", b"sentinel-must-not-leak") shared_oob.data.extend(bytes(BLOCK_SIZE)) shared_oob.data[declared_end : declared_end + len(sentinel)] = sentinel body = shared_inode.offset + shared_inode.inode_size shared_oob_log.u32( shared_oob, "/shared-a.shared_key_id", body + 12 + target_array_index * 4, declared_end // 4, ) save_variant( output, "bad-shared-declared-bounds.erofs", shared_oob, shared_oob_log, transforms, "basic.erofs", {"sentinel_offset": declared_end, "old_shared_id": target_id}, ) prefix_oob = basic.clone() prefix_oob_log = TransformLog() prefix_declared_end = prefix_oob.declared_size sentinel_prefix = prefix_record(XATTR_INDEX_USER, b"sentinel-never-visible.") prefix_oob.data.extend(bytes(BLOCK_SIZE)) prefix_oob.data[prefix_declared_end : prefix_declared_end + len(sentinel_prefix)] = sentinel_prefix prefix_oob_log.u32( prefix_oob, "super.feature_compat", SUPER + 8, prefix_oob.feature_compat | FEATURE_COMPAT_PLAIN_XATTR_PFX, ) prefix_oob_log.u8(prefix_oob, "super.xattr_prefix_count", SUPER + 91, 1) prefix_oob_log.u32( prefix_oob, "super.xattr_prefix_start", SUPER + 92, prefix_declared_end // 4, ) prefix_oob_log.u64(prefix_oob, "super.packed_nid", SUPER + 96, 0) save_variant( output, "bad-prefix-declared-bounds.erofs", prefix_oob, prefix_oob_log, transforms, "basic.erofs", {"sentinel_offset": prefix_declared_end}, ) truncated = basic.clone() truncated_log = TransformLog() _, truncated_carrier = truncated.resolve_root("/metabox-carrier.bin") truncated_log.u32( truncated, "super.feature_incompat", SUPER + 80, truncated.feature_incompat | FEATURE_INCOMPAT_METABOX, ) truncated_log.u64(truncated, "super.metabox_nid", SUPER + 128, truncated_carrier.nid) save_variant(output, "bad-metabox-truncated-extension.erofs", truncated, truncated_log, transforms, "basic.erofs") ishare = basic.clone() ishare_log = TransformLog() ishare_log.u32( ishare, "super.feature_compat", SUPER + 8, ishare.feature_compat | FEATURE_COMPAT_ISHARE_XATTRS, ) ishare_log.u8( ishare, "super.ishare_xattr_prefix_id", SUPER + 105, ishare.data[SUPER + 91], ) save_variant(output, "bad-ishare-prefix-id.erofs", ishare, ishare_log, transforms, "basic.erofs") positive_fragment_path = output / "images" / "metabox-fragment.erofs" positive_fragment = ErofsImage.load(positive_fragment_path) positive_semantics = transforms["metabox-fragment.erofs"]["semantics"] self_loop = positive_fragment.clone() self_loop_log = TransformLog() self_loop_log.u64( self_loop, "super.packed_nid", SUPER + 96, int(positive_semantics["carrier_nid"]), ) save_variant(output, "bad-fragment-self-loop.erofs", self_loop, self_loop_log, transforms, "metabox-fragment.erofs") range_bad = positive_fragment.clone() range_log = TransformLog() range_log.u64( range_bad, "metabox.fragment_header", int(positive_semantics["fragment_header_offset"]), METABOX_NID_BIT | int(positive_semantics["packed_size"]), ) save_variant(output, "bad-fragment-range.erofs", range_bad, range_log, transforms, "metabox-fragment.erofs") recursive_metabox = positive_fragment.clone() recursive_metabox_log = TransformLog() recursive_metabox_log.u64( recursive_metabox, "super.metabox_nid", SUPER + 128, METABOX_NID_BIT | int(positive_semantics["carrier_nid"]), ) save_variant( output, "bad-metabox-recursive-nid.erofs", recursive_metabox, recursive_metabox_log, transforms, "metabox-fragment.erofs", ) recursive_packed = positive_fragment.clone() recursive_packed_log = TransformLog() recursive_packed_log.u64( recursive_packed, "super.packed_nid", SUPER + 96, METABOX_NID_BIT | int(positive_semantics["packed_nid"]), ) save_variant( output, "bad-packed-recursive-nid.erofs", recursive_packed, recursive_packed_log, transforms, "metabox-fragment.erofs", ) return {"base_commands": base_commands, "images": transforms} def write_checksums(output: Path) -> None: paths = sorted((output / "bases").glob("*.erofs")) + sorted((output / "images").glob("*.erofs")) with (output / "IMAGE-SHA256SUMS").open("w", encoding="ascii") as sums: for path in paths: sums.write(f"{sha256_path(path)} {path.relative_to(output)}\n") def verify_output(output: Path) -> None: manifest = json.loads((output / "fixture-manifest.json").read_text(encoding="ascii")) inventory = source_inventory(output / "source") if inventory["sha256"] != manifest["source"]["sha256"]: raise FixtureError("source inventory hash changed") for name, item in manifest["transforms"]["images"].items(): path = output / "images" / name if sha256_path(path) != item["image_sha256"]: raise FixtureError(f"{name}: image hash changed") data = path.read_bytes() for field in item["fields"]: offset = field["offset"] size = field["size"] if data[offset : offset + size].hex() != field["after_hex"]: raise FixtureError(f"{name}: field check failed: {field['field']}") image = ErofsImage.load(path) if image.declared_size != item["declared_size"]: raise FixtureError(f"{name}: declared size changed") if len(data) != item["provider_size"]: raise FixtureError(f"{name}: provider size changed") checksums = {} for line in (output / "IMAGE-SHA256SUMS").read_text(encoding="ascii").splitlines(): digest, relative = line.split(" ", 1) checksums[relative] = digest for relative, digest in checksums.items(): if sha256_path(output / relative) != digest: raise FixtureError(f"checksum list mismatch: {relative}") def make(output: Path) -> None: if shutil.which("mkfs.erofs") is None: raise FixtureError("mkfs.erofs is required") if output.exists(): raise FixtureError(f"output already exists: {output}") output.mkdir(parents=True) version = tool_version() source_details = make_sources(output) source = source_inventory(output / "source") bases = output / "bases" bases.mkdir() base_commands = { "basic-base.erofs": run_mkfs( output / "source" / "basic", bases / "basic-base.erofs", "44444444-4444-4444-8444-444444444404", ), "carrier-plain-base.erofs": run_mkfs( output / "source" / "carrier", bases / "carrier-plain-base.erofs", "44444444-4444-4444-8444-444444444434", ), "carrier-compressed-base.erofs": run_mkfs( output / "source" / "carrier", bases / "carrier-compressed-base.erofs", "44444444-4444-4444-8444-444444444440", "-zlz4", "-C4096", "-Elegacy-compress", ), "carrier-fragment-base.erofs": run_mkfs( output / "source" / "carrier", bases / "carrier-fragment-base.erofs", "44444444-4444-4444-8444-444444444442", "-zlz4", "-C4096", "-Eall-fragments", ), } transforms = make_variants(output, base_commands) write_checksums(output) manifest = { "generator": "g4_fixtures.py", "mkfs_version": version, "source": source, "source_details": source_details, "transforms": transforms, } (output / "fixture-manifest.json").write_text( json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii" ) (output / "SOURCE-SHA256").write_text(f"{source['sha256']} source-inventory\n", encoding="ascii") (output / "mkfs-version.txt").write_text(version + "\n", encoding="ascii") verify_output(output) print(f"source_sha256={source['sha256']}") print((output / "IMAGE-SHA256SUMS").read_text(encoding="ascii"), end="") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) make_parser = subparsers.add_parser("make") make_parser.add_argument("--output", required=True, type=Path) verify_parser = subparsers.add_parser("verify") verify_parser.add_argument("--output", required=True, type=Path) return parser.parse_args() def main() -> None: args = parse_args() if args.command == "make": make(args.output) else: verify_output(args.output) if __name__ == "__main__": try: main() except (FixtureError, OSError, subprocess.CalledProcessError) as error: raise SystemExit(f"g4_fixtures.py: {error}") from error