#!/usr/bin/env python3 """Build and self-check fixtures for TC157-TC160.""" from __future__ import annotations import argparse import json import os from pathlib import Path import shutil import stat import struct import subprocess from review_fixtures import ( EROFS_INODE_COMPRESSED_FULL, ErofsImage, FixtureError, SUPER, align, normalize_times, run_mkfs, sha256, ) FEATURE_INCOMPAT_48BIT = 0x80 EROFS_INODE_FLAT_PLAIN = 0 EROFS_I_DOT_OMITTED = 1 << 4 Z_EROFS_ADVISE_EXTENTS = 0x1 OFF_MAX = (1 << 63) - 1 def map_header_offset(inode) -> int: return align(inode.offset + inode.inode_size + inode.xattr_size, 8) def record_size(advise: int) -> int: return 4 << ((advise >> 1) & 3) def binary_search_indices(lstarts: list[int], logical: int) -> list[int]: visited: list[int] = [] left = 0 right = len(lstarts) while left < right: middle = left + (right - left) // 2 visited.append(middle) if lstarts[middle] > logical: right = middle else: left = middle + 1 if lstarts[middle] == logical: right = min(left + 1, right) return visited def convert_extent_table( base: ErofsImage, inode, recsz: int, lstarts: list[int], payload: int, ) -> tuple[ErofsImage, int, int]: image = base.clone() header = map_header_offset(inode) table = align(header + 8, recsz) table_end = table + recsz * len(lstarts) if table_end > len(image.data): raise FixtureError("explicit extent table exceeds the base image") root = image.inode(image.root_nid) if not ( table_end <= root.offset or root.offset + root.inode_size <= header ): raise FixtureError("explicit extent conversion overlaps the root inode") advise = Z_EROFS_ADVISE_EXTENTS | ({16: 2, 32: 3}[recsz] << 1) struct.pack_into("> 32, lstart & 0xFFFFFFFF, ) if recsz == 32: struct.pack_into("> 32) image.update_checksum() return image, header, payload def verify_extent_fixture( image: ErofsImage, inode, recsz: int, expected: list[int], kind: str, logical_probe: int, ) -> tuple[int, list[int]]: header = map_header_offset(inode) count = image.u32(header) | (image.u16(header + 6) << 32) advise = image.u16(header + 4) if count != len(expected) or record_size(advise) != recsz: raise FixtureError("explicit extent header self-check failed") table = align(header + 8, recsz) observed: list[int] = [] for index in range(count): offset = table + index * recsz value = image.u32(offset + 12) if recsz == 32: value |= image.u32(offset + 16) << 32 observed.append(value) if observed != expected or any(value >= inode.size for value in observed): raise FixtureError("explicit extent lstart self-check failed") violations = [ index for index in range(1, len(observed)) if observed[index] <= observed[index - 1] ] if len(violations) != 1: raise FixtureError("fixture must contain exactly one ordering violation") violation = violations[0] if kind == "duplicate" and observed[violation] != observed[violation - 1]: raise FixtureError("duplicate fixture is not a duplicate") if kind != "duplicate" and observed[violation] >= observed[violation - 1]: raise FixtureError("descending fixture is not descending") visited = binary_search_indices(observed, logical_probe) if kind == "cross-branch" and violation - 1 in visited: raise FixtureError("cross-branch violation is visible to the old search") return table, visited def make_extent_fixtures(output: Path, source: Path) -> tuple[list[str], dict]: target_source = source / "extent.bin" target_source.write_bytes(b"extent-ordering-payload\n" * 65536) normalize_times(source) base_path = output / ".extent-base.erofs" run_mkfs( source, base_path, "77777777-1111-4222-8333-000000000157", "-E", "legacy-compress,force-inode-extended", "-z", "lz4", "-C4096", ) base = ErofsImage.load(base_path) inode = base.resolve_root_entry("extent.bin") if inode.inode_size != 64 or inode.layout != EROFS_INODE_COMPRESSED_FULL: raise FixtureError("extent target is not extended COMPRESSED_FULL") header = map_header_offset(inode) payload = base.u32(header + 20) << base.block_bits if payload == 0 or payload + base.block_size > header: raise FixtureError("original compressed payload is not isolated from metadata") cases = { "descending": ([0, 4096, 12288, 8192], 8192), "duplicate": ([0, 4096, 4096, 12288], 4096), "cross-branch": ([0, 8192, 4096, 12288], 4096), } evidence: list[str] = [] manifest_cases: dict[str, object] = {} for recsz in (16, 32): for kind, (lstarts, logical_probe) in cases.items(): image, header, payload = convert_extent_table( base, inode, recsz, lstarts, payload ) table, visited = verify_extent_fixture( image, inode, recsz, lstarts, kind, logical_probe ) name = f"extent-{recsz}-{kind}.erofs" path = output / name image.save(path) evidence.append( "extent-order " f"image={name} nid={inode.nid} recsize={recsz} " f"header={header} table={table} lstarts={lstarts} " f"probe={logical_probe} old_search_indices={visited} " f"payload_offset={payload} violation={kind}" ) manifest_cases[name] = { "path": "/extent.bin", "nid": inode.nid, "record_size": recsz, "header_offset": header, "table_offset": table, "lstarts": lstarts, "logical_probe": logical_probe, "old_search_indices": visited, "payload_offset": payload, "payload_length": image.block_size, "expected_errno": 97, } base_path.unlink() return evidence, {"cases": manifest_cases} def make_blocks_fixture(output: Path, source: Path) -> tuple[str, dict]: target_source = source / "compressed-blocks.bin" target_source.write_bytes(bytes(range(256)) * 4096) normalize_times(source) base_path = output / ".blocks-base.erofs" run_mkfs( source, base_path, "77777777-1111-4222-8333-000000000158", "-E", "legacy-compress,force-inode-extended", "-z", "lz4", "-C4096", ) image = ErofsImage.load(base_path) inode = image.resolve_root_entry("compressed-blocks.bin") if inode.inode_size != 64 or inode.layout != EROFS_INODE_COMPRESSED_FULL: raise FixtureError("compressed block target has the wrong inode layout") root_nid = image.root_nid inode_blocks_lo = image.u32(inode.offset + 16) if inode_blocks_lo == 0: raise FixtureError("compressed target has zero blocks_lo") super_blocks_lo = image.u32(SUPER + 36) image.put_u64(SUPER + 112, root_nid) image.put_u32( SUPER + 80, image.u32(SUPER + 80) | FEATURE_INCOMPAT_48BIT ) image.put_u16(SUPER + 14, 1) image.put_u16(inode.offset + 6, 1) image.update_checksum() path = output / "compressed-blocks-hi.erofs" image.save(path) base_path.unlink() data_blocks = (1 << 32) | inode_blocks_lo va_bytes = data_blocks << image.block_bits st_blocks = va_bytes // 512 provider_blocks = (1 << 32) | super_blocks_lo provider_size = provider_blocks << image.block_bits if image.root_nid != root_nid or image.u16(inode.offset + 6) != 1: raise FixtureError("48-bit inode/superblock self-check failed") if provider_size <= 16 * 1024**4: raise FixtureError("sparse provider is not larger than 16 TiB") evidence = ( "compressed-blocks-hi " f"nid={inode.nid} inode_offset={inode.offset} blocks_lo={inode_blocks_lo} " f"blocks_hi=1 data_blocks={data_blocks} block_size={image.block_size} " f"va_bytes={va_bytes} st_blocks={st_blocks} " f"provider_blocks={provider_blocks} provider_size={provider_size}" ) manifest = { "image": path.name, "path": "/compressed-blocks.bin", "nid": inode.nid, "inode_offset": inode.offset, "blocks_lo": inode_blocks_lo, "blocks_hi": 1, "data_blocks": data_blocks, "block_size": image.block_size, "va_bytes": va_bytes, "st_blocks": st_blocks, "provider_size": provider_size, "seed_size": len(image.data), } return evidence, manifest def make_special_fixture(output: Path, source: Path) -> tuple[str, dict]: fifo = source / "special.fifo" os.mkfifo(fifo, 0o640) normalize_times(source) path = output / "special-setattr.erofs" run_mkfs( source, path, "77777777-1111-4222-8333-000000000159", "-E", "force-inode-extended", ) image = ErofsImage.load(path) inode = image.resolve_root_entry("special.fifo") mode = image.u16(inode.offset + 4) if not stat.S_ISFIFO(mode): raise FixtureError("special setattr target is not a FIFO") evidence = ( f"special-setattr nid={inode.nid} inode_offset={inode.offset} " f"mode={mode:#o} type=fifo" ) return evidence, { "image": path.name, "path": "/special.fifo", "nid": inode.nid, "mode": mode, } def make_offmax_fixture(output: Path, source: Path) -> tuple[str, dict]: directory = source / "offmax-dir" directory.mkdir() for index in range(384): (directory / f"entry-{index:03d}").write_text( f"entry {index}\n", encoding="ascii" ) normalize_times(source) base_path = output / ".offmax-base.erofs" run_mkfs( source, base_path, "77777777-1111-4222-8333-000000000160", "-E", "force-inode-extended", ) image = ErofsImage.load(base_path) inode = image.resolve_root_entry("offmax-dir") mode = image.u16(inode.offset + 4) if inode.inode_size != 64 or not stat.S_ISDIR(mode) or inode.start_block == 0: raise FixtureError("OFF_MAX target is not an extended backed directory") inode_format = image.u16(inode.offset) inode_format &= ~0x0E inode_format |= EROFS_I_DOT_OMITTED image.put_u16(inode.offset, inode_format) image.put_u64(inode.offset + 8, OFF_MAX) image.update_checksum() path = output / "dot-omitted-offmax.erofs" image.save(path) base_path.unlink() mutated = image.resolve_root_entry("offmax-dir") if ( mutated.inode_size != 64 or mutated.layout != EROFS_INODE_FLAT_PLAIN or mutated.size != OFF_MAX or not image.u16(mutated.offset) & EROFS_I_DOT_OMITTED ): raise FixtureError("OFF_MAX directory field self-check failed") evidence = ( f"dot-omitted-offmax nid={mutated.nid} inode_offset={mutated.offset} " f"format={image.u16(mutated.offset):#x} size={mutated.size} " f"start_block={mutated.start_block} expected_errno=97" ) return evidence, { "image": path.name, "path": "/offmax-dir", "nid": mutated.nid, "inode_offset": mutated.offset, "size": mutated.size, "expected_errno": 97, } 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) sources = output / ".sources" extent_source = sources / "extent" blocks_source = sources / "blocks" special_source = sources / "special" offmax_source = sources / "offmax" for source in (extent_source, blocks_source, special_source, offmax_source): source.mkdir(parents=True) evidence, extent_manifest = make_extent_fixtures(output, extent_source) blocks_evidence, blocks_manifest = make_blocks_fixture(output, blocks_source) special_evidence, special_manifest = make_special_fixture(output, special_source) offmax_evidence, offmax_manifest = make_offmax_fixture(output, offmax_source) evidence.extend([blocks_evidence, special_evidence, offmax_evidence]) manifest = { "extent_order": extent_manifest, "compressed_blocks_hi": blocks_manifest, "special_setattr": special_manifest, "dot_omitted_offmax": offmax_manifest, } shutil.rmtree(sources) (output / "fixture-evidence.txt").write_text( "\n".join(evidence) + "\n", encoding="ascii" ) (output / "fixture-manifest.json").write_text( json.dumps(manifest, 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(path)} {path.name}\n") print((output / "fixture-evidence.txt").read_text(encoding="ascii"), end="") print((output / "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", 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"final_review_fixtures.py: {error}") from error