#!/usr/bin/env python3 """Build and self-check fixtures for TC154-TC156.""" from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import shutil import struct import subprocess SUPER = 1024 EROFS_MAGIC = 0xE0F5E1E2 FEATURE_COMPAT_SB_CHKSUM = 0x1 EROFS_INODE_FLAT_PLAIN = 0 EROFS_INODE_FLAT_INLINE = 2 EROFS_INODE_COMPRESSED_FULL = 1 Z_EROFS_ADVISE_EXTENTS = 0x1 UINT64_MAX = (1 << 64) - 1 INT64_MAX = (1 << 63) - 1 FNV1_32_INIT = 33554467 FNV_32_PRIME = 0x01000193 class FixtureError(RuntimeError): pass class Inode: def __init__( self, nid: int, offset: int, inode_format: int, inode_size: int, xattr_size: int, layout: int, size: int, start_block: int, ) -> None: self.nid = nid self.offset = offset self.inode_format = inode_format self.inode_size = inode_size self.xattr_size = xattr_size self.layout = layout self.size = size self.start_block = start_block class ErofsImage: def __init__(self, data: bytes | bytearray) -> None: self.data = bytearray(data) self.validate_superblock() @classmethod def load(cls, path: Path) -> "ErofsImage": return cls(path.read_bytes()) def clone(self) -> "ErofsImage": return ErofsImage(self.data) 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: if not 9 <= self.block_bits <= 16: raise FixtureError(f"invalid block bits: {self.block_bits}") return 1 << self.block_bits @property def super_size(self) -> int: return 128 + self.data[SUPER + 13] * 16 @property def checksum_end(self) -> int: length = self.block_size if length > SUPER: length -= SUPER return SUPER + length @property def feature_compat(self) -> int: return self.u32(SUPER + 8) @property def root_nid(self) -> int: feature_incompat = self.u32(SUPER + 80) root_nid_8b = self.u64(SUPER + 112) if feature_incompat & 0x80 and root_nid_8b != 0: return root_nid_8b return self.u16(SUPER + 14) def calculated_checksum(self) -> int: window = bytearray(self.data[SUPER : self.checksum_end]) struct.pack_into("> 1) ^ ( polynomial if checksum & 1 else 0 ) return checksum & 0xFFFFFFFF def checksum_valid(self) -> bool: if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM: return True return self.u32(SUPER + 4) == self.calculated_checksum() def update_checksum(self) -> None: if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM: raise FixtureError("base image lacks superblock checksum support") self.put_u32(SUPER + 4, 0) self.put_u32(SUPER + 4, self.calculated_checksum()) if not self.checksum_valid(): raise FixtureError("updated superblock checksum does not verify") def validate_superblock(self) -> None: if len(self.data) < SUPER + 144: raise FixtureError("image is too short for an EROFS superblock") if self.u32(SUPER) != EROFS_MAGIC: raise FixtureError(f"bad EROFS magic: {self.u32(SUPER):#x}") if SUPER + self.super_size > len(self.data): raise FixtureError("declared superblock is truncated") if not self.checksum_valid(): raise FixtureError("superblock checksum is invalid") def inode(self, nid: int) -> Inode: metadata = self.u32(SUPER + 40) << self.block_bits offset = metadata + (nid << 5) if offset > len(self.data) - 32: raise FixtureError(f"nid {nid} lies outside the image") inode_format = self.u16(offset) inode_size = 64 if inode_format & 1 else 32 if offset > len(self.data) - inode_size: raise FixtureError(f"nid {nid} has a truncated inode") xattr_count = self.u16(offset + 2) xattr_size = 0 if xattr_count == 0 else 12 + 4 * (xattr_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, size=size, start_block=self.u32(offset + 16), ) def directory_data(self, inode: Inode) -> bytes: if inode.size == 0: raise FixtureError("empty directory cannot contain a target") if inode.layout == EROFS_INODE_FLAT_PLAIN: offset = inode.start_block << self.block_bits end = offset + inode.size if end > len(self.data): raise FixtureError("plain directory data is truncated") return bytes(self.data[offset:end]) if inode.layout != EROFS_INODE_FLAT_INLINE: raise FixtureError(f"unsupported directory layout {inode.layout}") tail_start = ((inode.size + self.block_size - 1) // self.block_size - 1) tail_start *= self.block_size full_offset = inode.start_block << self.block_bits inline_offset = inode.offset + inode.inode_size + inode.xattr_size full = self.data[full_offset : full_offset + tail_start] tail = self.data[inline_offset : inline_offset + inode.size - tail_start] if len(full) + len(tail) != inode.size: raise FixtureError("inline directory data is truncated") return bytes(full + tail) def directory_entries(self, inode: Inode) -> list[tuple[bytes, int]]: data = self.directory_data(inode) entries: list[tuple[bytes, int]] = [] 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_nameoff = struct.unpack_from("= len(block) ): raise FixtureError("invalid first directory name offset") count = first_nameoff // 12 previous = 0 for index in range(count): entry_offset = index * 12 nid = struct.unpack_from(" len(block) ): raise FixtureError("invalid directory name range") name = block[nameoff:endoff].split(b"\0", 1)[0] if not name: raise FixtureError("empty directory name") entries.append((name, nid)) previous = nameoff return entries def resolve_root_entry(self, name: str) -> Inode: encoded = name.encode("ascii") root = self.inode(self.root_nid) for entry_name, nid in self.directory_entries(root): if entry_name == encoded: return self.inode(nid) raise FixtureError(f"root entry not found: {name}") def inode_bytes(self, inode: Inode) -> bytes: return bytes(self.data[inode.offset : inode.offset + inode.inode_size]) def generation(self, inode: Inode) -> int: seed = fnv1_32( bytes(self.data[SUPER : SUPER + self.super_size]), FNV1_32_INIT ) generation = fnv1_32(struct.pack(" None: self.validate_superblock() path.write_bytes(self.data) def fnv1_32(data: bytes, initial: int) -> int: value = initial for byte in data: value = (value * FNV_32_PRIME) & 0xFFFFFFFF value ^= byte return value def align(value: int, alignment: int) -> int: return (value + alignment - 1) & -alignment def run_mkfs(source: Path, image: Path, uuid: str, *extra: str) -> None: command = [ "mkfs.erofs", "-d0", "-x-1", "-T0", "--all-time", "--all-root", "--workers=1", "--sort=path", "-U", uuid, *extra, str(image), str(source), ] subprocess.run(command, check=True) 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 sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def make_fixtures(output: Path) -> None: if shutil.which("mkfs.erofs") is None: raise FixtureError("mkfs.erofs is required") if output.exists() and any(output.iterdir()): raise FixtureError(f"output directory is not empty: {output}") output.mkdir(parents=True, exist_ok=True) source = output / "source" nfs_source = source / "nfs" extent_source = source / "extent" extended_source = source / "extended" nfs_source.mkdir(parents=True) extent_source.mkdir(parents=True) extended_source.mkdir(parents=True) for index in range(192): (nfs_source / f"filler-{index:03d}.txt").write_text( f"filler {index:03d}\n", encoding="ascii" ) (nfs_source / "zz-identity.txt").write_text( "stable inode identity\n", encoding="ascii" ) (extent_source / "extent.bin").write_bytes(b"E" * (1024 * 1024)) (extended_source / "extended-time.txt").write_text( "extended timestamp\n", encoding="ascii" ) normalize_times(source) nfs_base_path = output / ".nfs-base.erofs" extent_base_path = output / ".extent-base.erofs" extended_base_path = output / ".extended-base.erofs" run_mkfs( nfs_source, nfs_base_path, "66666666-7777-4888-9999-aaaaaaaaaa54", "-E", "force-inode-compact", ) run_mkfs( extent_source, extent_base_path, "66666666-7777-4888-9999-aaaaaaaaaa55", "-E", "legacy-compress,force-inode-extended", "-z", "lz4", "-C4096", ) run_mkfs( extended_source, extended_base_path, "66666666-7777-4888-9999-aaaaaaaaaa56", "-E", "force-inode-extended", ) evidence: list[str] = [] manifest: dict[str, object] = {} nfs_a = ErofsImage.load(nfs_base_path) identity_a = nfs_a.resolve_root_entry("zz-identity.txt") if identity_a.inode_size != 32: raise FixtureError("NFS identity target is not a compact inode") if identity_a.offset < nfs_a.checksum_end: raise FixtureError("NFS identity inode overlaps the checksum window") nfs_a_path = output / "nfs-inode-a.erofs" nfs_a.save(nfs_a_path) nfs_b = nfs_a.clone() identity_b = nfs_b.resolve_root_entry("zz-identity.txt") old_mtime = nfs_b.u32(identity_b.offset + 12) nfs_b.put_u32(identity_b.offset + 12, old_mtime + 1) nfs_b_path = output / "nfs-inode-b.erofs" nfs_b.save(nfs_b_path) nfs_differences = [ offset for offset, (left, right) in enumerate(zip(nfs_a.data, nfs_b.data)) if left != right ] mtime_offsets = range(identity_b.offset + 12, identity_b.offset + 16) if not nfs_differences or any( offset not in mtime_offsets for offset in nfs_differences ): raise FixtureError("NFS replacement changed data outside i_mtime") if nfs_a.data[: nfs_a.checksum_end] != nfs_b.data[: nfs_b.checksum_end]: raise FixtureError("NFS replacement changed the superblock block") if nfs_a.data[SUPER : SUPER + nfs_a.super_size] != nfs_b.data[ SUPER : SUPER + nfs_b.super_size ]: raise FixtureError("NFS replacement changed the declared superblock") if nfs_a.inode_bytes(identity_a) == nfs_b.inode_bytes(identity_b): raise FixtureError("NFS replacement did not change inode metadata") generation_a = nfs_a.generation(identity_a) generation_b = nfs_b.generation(identity_b) if generation_a == generation_b: raise FixtureError("per-inode generation did not change") nfs_corrupt = nfs_a.clone() corrupt_inode = nfs_corrupt.resolve_root_entry("zz-identity.txt") nfs_corrupt.put_u16( corrupt_inode.offset, nfs_corrupt.u16(corrupt_inode.offset) | 0x8000 ) nfs_corrupt_path = output / "nfs-inode-corrupt.erofs" nfs_corrupt.save(nfs_corrupt_path) if nfs_a.data[: nfs_a.checksum_end] != nfs_corrupt.data[: nfs_a.checksum_end]: raise FixtureError("corrupt inode fixture changed the superblock block") evidence.append( "nfs " f"nid={identity_a.nid} inode_offset={identity_a.offset} " f"checksum_end={nfs_a.checksum_end} mtime={old_mtime}->{old_mtime + 1} " f"generation={generation_a}->{generation_b} superblock_block=identical" ) manifest["nfs"] = { "path": "/zz-identity.txt", "nid": identity_a.nid, "inode_offset": identity_a.offset, "generation_a": generation_a, "generation_b": generation_b, "super_size": nfs_a.super_size, "checksum_end": nfs_a.checksum_end, } extent = ErofsImage.load(extent_base_path) extent_inode = extent.resolve_root_entry("extent.bin") if ( extent_inode.inode_size != 64 or extent_inode.layout != EROFS_INODE_COMPRESSED_FULL ): raise FixtureError("extent target is not an extended full-index inode") header = align( extent_inode.offset + extent_inode.inode_size + extent_inode.xattr_size, 8, ) physical_base_offset = align(header + 8, 4) record_offset = physical_base_offset + 8 if record_offset + 8 > len(extent.data): raise FixtureError("extent records lie outside the base image") root_inode = extent.inode(extent.root_nid) if not ( record_offset + 8 <= root_inode.offset or root_inode.offset + root_inode.inode_size <= header ): raise FixtureError("extent conversion overlaps the root inode") struct.pack_into( " 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", type=Path, required=True) return parser.parse_args() def main() -> None: args = parse_args() if args.command == "make": make_fixtures(args.output) if __name__ == "__main__": try: main() except (FixtureError, OSError, subprocess.CalledProcessError) as error: raise SystemExit(f"review_fixtures.py: {error}") from error