#!/usr/bin/env python3 """Generate deterministic EROFS explicit-extent fixtures for Pre15 B23.""" 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_INCOMPAT_METABOX = 0x00000100 METABOX_NID_BIT = 1 << 63 LAYOUT_FLAT_PLAIN = 0 LAYOUT_COMPRESSED_FULL = 1 Z_EROFS_ADVISE_EXTENTS = 0x0001 BLOCK_SIZE = 4096 CARRIER_BYTES = 512 * 1024 CRC32C_POLYNOMIAL = 0x82F63B78 FIXED_UUID = "23232323-1515-4234-8123-000000000023" class FixtureError(RuntimeError): pass @dataclass(frozen=True) class Inode: nid: int offset: int inode_size: int layout: int size: int start_block: int @dataclass(frozen=True) class DirectoryEntry: name: bytes nid: int offset: int 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 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 Image: def __init__(self, data: bytes | bytearray) -> None: self.data = bytearray(data) self.validate_super() @classmethod def load(cls, path: Path) -> "Image": return cls(path.read_bytes()) def clone(self) -> "Image": return Image(self.data) def u16(self, offset: int) -> int: return struct.unpack_from(" int: return struct.unpack_from(" int: return struct.unpack_from(" None: self.data[offset] = value def put_u16(self, offset: int, value: int) -> 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 meta_offset(self) -> int: return self.u32(SUPER + 40) << self.block_bits @property def root_nid(self) -> int: return self.u16(SUPER + 14) @property def blocks(self) -> int: return self.u32(SUPER + 36) @property def declared_size(self) -> int: return self.blocks << self.block_bits def validate_super(self) -> None: if len(self.data) < SUPER + 144 or self.u32(SUPER) != MAGIC: raise FixtureError("invalid EROFS superblock") if self.block_size != BLOCK_SIZE: raise FixtureError("B23 requires a 4096-byte EROFS block") if self.declared_size > len(self.data): raise FixtureError("declared EROFS image exceeds provider bytes") if self.u32(SUPER + 8) & FEATURE_COMPAT_SB_CHKSUM: window = bytearray(self.data[SUPER : self.block_size]) expected = struct.unpack_from(" None: if not self.u32(SUPER + 8) & FEATURE_COMPAT_SB_CHKSUM: raise FixtureError("B23 seed lacks the checksum feature") self.put_u32(SUPER + 4, 0) self.put_u32(SUPER + 4, crc32c(self.data[SUPER : self.block_size])) def inode(self, nid: int) -> Inode: offset = self.meta_offset + (nid << 5) if offset + 32 > self.declared_size: raise FixtureError(f"inode {nid} exceeds the primary metadata") inode_format = self.u16(offset) inode_size = 64 if inode_format & 1 else 32 size = self.u64(offset + 8) if inode_size == 64 else self.u32(offset + 8) return Inode( nid=nid, offset=offset, inode_size=inode_size, layout=(inode_format >> 1) & 7, size=size, start_block=self.u32(offset + 16), ) def inode_data(self, inode: Inode) -> tuple[bytes, int]: 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 image") return bytes(self.data[start:end]), start if inode.layout != 2: raise FixtureError(f"unsupported host inode layout {inode.layout}") start = inode.offset + inode.inode_size end = start + inode.size if end > self.declared_size: raise FixtureError("inline inode data exceeds the image") return bytes(self.data[start:end]), start def directory_entries(self, inode: Inode) -> list[DirectoryEntry]: data, data_base = self.inode_data(inode) entries: list[DirectoryEntry] = [] 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 entry table") count = first_name // 12 for index in range(count): item = index * 12 name_start = struct.unpack_from(" tuple[DirectoryEntry, Inode]: encoded = name.encode("ascii") for entry in self.directory_entries(self.inode(self.root_nid)): if entry.name == encoded: return entry, self.inode(entry.nid) raise FixtureError(f"root entry not found: {name}") def ensure_size(self, end: int) -> None: rounded = align(end, self.block_size) if len(self.data) < rounded: self.data.extend(bytes(rounded - len(self.data))) self.put_u32(SUPER + 36, rounded >> self.block_bits) 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 payload_block() -> bytes: return bytes((index * 37 + 11) & 0xFF for index in range(BLOCK_SIZE)) def make_seed(output: Path, expected_version: str) -> Image: source = output / ".source" source.mkdir() (source / "target.bin").write_bytes(b"placeholder\n") (source / "payload.bin").write_bytes(payload_block()) (source / "carrier.bin").write_bytes(bytes(CARRIER_BYTES)) normalize_times(source) version_output = subprocess.run( ["mkfs.erofs", "-V"], check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ).stdout.strip() version = version_output.splitlines()[0] if version_output else "" if version != expected_version: raise FixtureError(f"mkfs version changed: {version!r}") seed = output / ".seed.erofs" command = [ "mkfs.erofs", "-d0", "-x-1", "-T0", "--all-time", "--all-root", "--workers=1", "--sort=path", "-U", FIXED_UUID, "-E", "force-inode-extended", str(seed), str(source), ] subprocess.run(command, check=True) shutil.rmtree(source) image = Image.load(seed) _, payload = image.resolve_root("payload.bin") _, carrier = image.resolve_root("carrier.bin") if payload.layout != LAYOUT_FLAT_PLAIN or payload.size != BLOCK_SIZE: raise FixtureError("payload seed is not one plain block") if carrier.layout != LAYOUT_FLAT_PLAIN or carrier.size != CARRIER_BYTES: raise FixtureError("carrier seed is not the expected plain file") return image def write_inode(image: Image, offset: int, size: int) -> None: image.data[offset : offset + 64] = bytes(64) image.put_u16(offset, 1 | (LAYOUT_COMPRESSED_FULL << 1)) image.put_u16(offset + 4, stat.S_IFREG | 0o444) image.put_u64(offset + 8, size) image.put_u32(offset + 16, 1) image.put_u32(offset + 44, 1) def logical_starts(case: dict[str, object]) -> tuple[list[int], int, list[int]]: count = int(case["count"]) mutation = case.get("mutation") if case.get("logical_mode") == "high32": starts = [0, 1 << 32, (1 << 32) + BLOCK_SIZE] return starts, (1 << 32) + 2 * BLOCK_SIZE, starts[1:] if mutation == "descending-lstart": starts = [0, 2 * BLOCK_SIZE, 3 * BLOCK_SIZE] return starts, 4 * BLOCK_SIZE, [] starts = [index * BLOCK_SIZE for index in range(count)] size = count * BLOCK_SIZE probes = sorted({0, ((count // 2) * BLOCK_SIZE), size - BLOCK_SIZE}) return starts, size, probes def extent_span(record_size: int, count: int) -> int: return 72 + (8 if record_size == 4 else 0) + record_size * count def write_extent_table( image: Image, storage_base: int, inode_offset: int, record_size: int, starts: list[int], file_size: int, payload_offset: int, ) -> dict[str, int]: write_inode(image, storage_base + inode_offset, file_size) header = align(inode_offset + 64, 8) table = align(header + 8, record_size) advise = Z_EROFS_ADVISE_EXTENTS | ({4: 0, 8: 1, 16: 2, 32: 3}[record_size] << 1) struct.pack_into( "> 32, ) records = table if record_size == 4: image.put_u64(storage_base + table, payload_offset) records += 8 for index, lstart in enumerate(starts): offset = storage_base + records + index * record_size image.data[offset : offset + record_size] = bytes(record_size) image.put_u32(offset, BLOCK_SIZE) if record_size >= 8: image.put_u32(offset + 4, payload_offset & 0xFFFFFFFF) if record_size >= 16: image.put_u32(offset + 8, payload_offset >> 32) image.put_u32(offset + 12, lstart & 0xFFFFFFFF) if record_size == 32: image.put_u32(offset + 16, lstart >> 32) return { "header": header, "table": table, "records": records, "end": records + record_size * len(starts), } def enable_metabox(image: Image) -> None: if image.meta_offset != 0: raise FixtureError("B23 metabox seed expects metadata block zero") metadata = bytes(image.data[: image.block_size]) relocated = align(len(image.data), image.block_size) image.ensure_size(relocated + image.block_size) image.data[relocated : relocated + image.block_size] = metadata image.put_u32(SUPER + 40, relocated >> image.block_bits) image.put_u8(SUPER + 13, 1) image.put_u32( SUPER + 80, image.u32(SUPER + 80) | FEATURE_INCOMPAT_METABOX ) def save_case( output: Path, seed: Image, case: dict[str, object], payload_offset: int, ) -> dict[str, object]: image = seed.clone() backing = str(case["backing"]) record_size = int(case["record_size"]) starts, file_size, probes = logical_starts(case) stored_count = len(starts) mutation = case.get("mutation") if backing == "primary": inode_absolute = align(len(image.data), image.block_size) + 4000 inode_offset = inode_absolute storage_base = 0 stored_end = inode_absolute + extent_span(record_size, stored_count) image.ensure_size(stored_end) target_entry, _ = image.resolve_root("target.bin") nid_delta = inode_absolute - image.meta_offset if nid_delta < 0 or nid_delta % 32: raise FixtureError("primary synthetic inode is not NID aligned") target_nid = nid_delta >> 5 image.put_u64(target_entry.offset, target_nid) carrier_inode_offset = None carrier_size = None elif backing == "metabox": enable_metabox(image) target_entry, _ = image.resolve_root("target.bin") _, carrier = image.resolve_root("carrier.bin") storage_base = carrier.start_block << image.block_bits span = extent_span(record_size, stored_count) inode_offset = ((CARRIER_BYTES - span) // 32) * 32 carrier_size = inode_offset + span if inode_offset < 4096 or carrier_size > CARRIER_BYTES: raise FixtureError("metabox explicit table does not fit the carrier") target_nid = METABOX_NID_BIT | (inode_offset >> 5) image.put_u64(target_entry.offset, target_nid) carrier_inode_offset = carrier.offset image.put_u64(carrier.offset + 8, carrier_size) image.put_u64(SUPER + 128, carrier.nid) else: raise FixtureError(f"unknown backing: {backing}") layout = write_extent_table( image, storage_base, inode_offset, record_size, starts, file_size, payload_offset, ) image.update_checksum() baseline = image.clone() mutation_record = None if mutation == "missing-tail": image.put_u32(storage_base + layout["header"], 2) mutation_record = { "field": "map_header.h_extents_lo", "offset": storage_base + layout["header"], "size": 4, } elif mutation == "huge-count": image.put_u16(storage_base + layout["header"] + 6, 256) mutation_record = { "field": "map_header.h_extents_hi", "offset": storage_base + layout["header"] + 6, "size": 2, } elif mutation == "descending-lstart": changed = storage_base + layout["records"] + 2 * record_size + 12 image.put_u32(changed, BLOCK_SIZE) mutation_record = { "field": "extent[2].lstart_lo", "offset": changed, "size": 4, } starts[2] = BLOCK_SIZE elif mutation == "tail-minus-one": if carrier_inode_offset is None or carrier_size is None: raise FixtureError("tail-minus-one requires metabox backing") image.put_u64(carrier_inode_offset + 8, carrier_size - 1) mutation_record = { "field": "metabox_carrier.i_size", "offset": carrier_inode_offset + 8, "size": 8, } elif mutation is not None: raise FixtureError(f"unknown mutation: {mutation}") name = str(case["name"]) image_path = output / f"{name}.erofs" image_path.write_bytes(image.data) baseline_name = None if mutation_record is not None: baseline_name = f"{name}-baseline.erofs" (output / baseline_name).write_bytes(baseline.data) declared_count = stored_count if mutation == "missing-tail": declared_count = 2 elif mutation == "huge-count": declared_count = 1 + (256 << 32) table_bytes = record_size * declared_count scan_calls = 1 if record_size <= 8 else ( (table_bytes + 65535) // 65536 if case["outcome"] == "PASS" else 1 ) return { "backing": backing, "baseline": baseline_name, "carrier_inode_offset": carrier_inode_offset, "carrier_size": carrier_size, "declared_count": declared_count, "expected_errno": 0 if case["outcome"] == "PASS" else 97, "expected_scan_calls": scan_calls, "file_size": file_size, "header_image_offset": storage_base + layout["header"], "header_logical_offset": layout["header"], "image": image_path.name, "image_sha256": sha256_bytes(image.data), "inode_image_offset": storage_base + inode_offset, "inode_logical_offset": inode_offset, "lstarts": starts, "mutation": mutation_record, "payload_offset": payload_offset, "probe_offsets": probes, "record_image_offset": storage_base + layout["records"], "record_logical_offset": layout["records"], "record_size": record_size, "stored_count": stored_count, "table_end_logical": layout["end"], "target_dirent_offset": target_entry.offset, "target_marker": case["target_marker"], "target_nid": target_nid, } def generate(spec_path: Path, output: Path) -> None: spec = json.loads(spec_path.read_text(encoding="ascii")) if spec.get("schema") != 1 or spec.get("batch") != "B23": raise FixtureError("invalid B23 spec identity") if output.exists() and any(output.iterdir()): raise FixtureError(f"output is not empty: {output}") output.mkdir(parents=True, exist_ok=True) seed = make_seed(output, str(spec["mkfs_version"])) _, payload = seed.resolve_root("payload.bin") payload_offset = payload.start_block << seed.block_bits if seed.data[payload_offset : payload_offset + BLOCK_SIZE] != payload_block(): raise FixtureError("plain payload bytes changed") cases: dict[str, object] = {} for case in spec["cases"]: cases[str(case["name"])] = save_case( output, seed, case, payload_offset ) (output / ".seed.erofs").unlink() manifest = { "batch": "B23", "cases": cases, "chunk_size": spec["chunk_size"], "mkfs_version": spec["mkfs_version"], "payload_sha256": sha256_bytes(payload_block()), "schema": 1, "test": spec["test"], } (output / "fixture-manifest.json").write_text( json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="ascii", ) with (output / "SHA256SUMS").open("w", encoding="ascii") as sums: for path in sorted(output.glob("*.erofs")): sums.write(f"{sha256_bytes(path.read_bytes())} {path.name}\n") print( f"B23 fixtures generated cases={len(cases)} " f"images={len(list(output.glob('*.erofs')))}" ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--spec", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) return parser.parse_args() def main() -> None: args = parse_args() generate(args.spec, args.output) if __name__ == "__main__": try: main() except (FixtureError, OSError, subprocess.CalledProcessError) as error: raise SystemExit(f"B23 fixture generation failed: {error}") from error