#!/usr/bin/env python3 """Independent multi-block directory and xattr oracle for B19a.""" from __future__ import annotations import argparse from concurrent.futures import ThreadPoolExecutor import hashlib import json import os from pathlib import Path import statistics import struct import threading import time SUPER = 1024 MAGIC = 0xE0F5E1E2 CRC32C_POLY = 0x82F63B78 class Reject(RuntimeError): def __init__(self, errno_name: str, point: str): super().__init__(f"{errno_name} at {point}") self.errno_name = errno_name self.point = point def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def crc32c(data: bytes, seed: int = 0xFFFFFFFF) -> int: value = seed for byte in data: value ^= byte for _ in range(8): value = (value >> 1) ^ (CRC32C_POLY if value & 1 else 0) return value & 0xFFFFFFFF def load_spec(path: Path) -> dict: spec = json.loads(path.read_text(encoding="ascii")) if ( spec.get("schema") != 1 or spec.get("batch") != "B19a" or spec.get("candidate") != "P15-022" ): raise SystemExit("invalid B19a oracle spec identity") return spec class ReadStats: def __init__(self) -> None: self.lock = threading.Lock() self.calls = 0 self.bytes = 0 self.block_reads = 0 def add(self, offset: int, length: int, block_size: int) -> None: with self.lock: self.calls += 1 self.bytes += length if length: self.block_reads += ( (offset + length - 1) // block_size - offset // block_size + 1 ) def reset(self) -> None: with self.lock: self.calls = 0 self.bytes = 0 self.block_reads = 0 def snapshot(self) -> dict[str, int]: with self.lock: return { "bytes": self.bytes, "calls": self.calls, "provider_block_reads": self.block_reads, } class Reader: def __init__(self, path: Path): self.path = path self.fd = os.open(path, os.O_RDONLY) self.size = os.fstat(self.fd).st_size self.stats = ReadStats() header = self.read(SUPER, 144, "super") if struct.unpack_from(" 16: raise Reject("EINTEGRITY", "super.block-size") self.block_size = 1 << self.block_bits self.root_nid = struct.unpack_from(" self.size: raise Reject("EINTEGRITY", "super.bounds") if self.feature_compat & 1: expected = struct.unpack_from(" None: os.close(self.fd) def __enter__(self) -> "Reader": return self def __exit__(self, *_: object) -> None: self.close() def read(self, offset: int, length: int, point: str) -> bytes: limit = self.limit if hasattr(self, "limit") else self.size if offset < 0 or length < 0 or offset > limit or length > limit - offset: raise Reject("EINTEGRITY", f"{point}.bounds") data = os.pread(self.fd, length, offset) if len(data) != length: raise Reject("EINTEGRITY", f"{point}.short") block_size = self.block_size if hasattr(self, "block_size") else 4096 self.stats.add(offset, length, block_size) return data def inode(self, nid: int) -> dict[str, int]: offset = (self.meta_blkaddr << self.block_bits) + (nid << 5) raw = self.read(offset, 64, "inode") inode_format, xattr_count = struct.unpack_from("> 1) & 7, "size": size, "start_block": struct.unpack_from(" bytes: if logical > inode["size"] or length > inode["size"] - logical: raise Reject("EINTEGRITY", "inode.data-range") if inode["layout"] == 0: physical = (inode["start_block"] << self.block_bits) + logical elif inode["layout"] == 2: tail_start = ((inode["size"] + self.block_size - 1) // self.block_size - 1) * self.block_size if logical < tail_start: if logical + length > tail_start: raise Reject("EINTEGRITY", "inode.inline-crossing") physical = (inode["start_block"] << self.block_bits) + logical else: physical = ( inode["offset"] + inode["inode_size"] + inode["xattr_size"] + logical - tail_start ) else: raise Reject("EOPNOTSUPP", "inode.layout") return self.read(physical, length, "inode.data") @staticmethod def parse_dirblock(data: bytes, block_index: int) -> list[dict[str, object]]: if len(data) < 12: raise Reject("EINTEGRITY", "directory.header") first_name = struct.unpack_from("= len(data) or first_name % 12: raise Reject("EINTEGRITY", "directory.name-offset") count = first_name // 12 entries = [] previous_offset = 0 previous_name: bytes | None = None for index in range(count): offset = index * 12 nid, start, file_type = struct.unpack_from("= len(data) or (index == 0 and start != first_name) or (index != 0 and start <= previous_offset) or end <= start or end > len(data) ): raise Reject("EINTEGRITY", "directory.name-bounds") span = data[start:end] if index + 1 < count: if b"\0" in span: raise Reject("EINTEGRITY", "directory.name-nul") name = span else: name = span.split(b"\0", 1)[0] if not name or len(name) > 255 or b"/" in name: raise Reject("EINTEGRITY", "directory.name") if previous_name is not None and previous_name >= name: raise Reject("EINTEGRITY", "directory.order") entries.append( { "block": block_index, "entry": index, "file_type": file_type, "name": name, "nid": nid, } ) previous_offset = start previous_name = name return entries @staticmethod def validate_boundary( left: list[dict[str, object]], right: list[dict[str, object]] ) -> None: if left[-1]["name"] >= right[0]["name"]: raise Reject("EINTEGRITY", "directory.boundary-order") @staticmethod def resolve_entries(entries: list[dict[str, object]], name: bytes) -> int: matches = [int(entry["nid"]) for entry in entries if entry["name"] == name] if not matches: raise Reject("ENOENT", "path.missing") if len(matches) != 1: raise Reject("EINTEGRITY", "path.duplicate") return matches[0] def directory_entries(self, inode: dict[str, int]) -> list[dict[str, object]]: entries = [] previous_name: bytes | None = None logical = 0 block_index = 0 while logical < inode["size"]: length = min(self.block_size, inode["size"] - logical) block_entries = self.parse_dirblock( self.inode_data(inode, logical, length), block_index ) if entries: self.validate_boundary(entries, block_entries) entries.extend(block_entries) previous_name = block_entries[-1]["name"] logical += length block_index += 1 return entries def resolve(self, path: str) -> dict[str, int]: inode = self.inode(self.root_nid) for component in path.strip("/").encode("ascii").split(b"/"): if not component: continue inode = self.inode( self.resolve_entries(self.directory_entries(inode), component) ) return inode def body_offset(self, inode: dict[str, int]) -> int: return inode["offset"] + inode["inode_size"] @staticmethod def parse_entry(raw: bytes, offset: int, limit: int, point: str) -> tuple[dict, int]: if offset > limit or 4 > limit - offset: raise Reject("EINTEGRITY", f"{point}.header") name_length, name_index, value_length = struct.unpack_from(" limit - offset: raise Reject("EINTEGRITY", f"{point}.bounds") name_start = offset + 4 name_end = name_start + name_length value_end = name_end + value_length name = raw[name_start:name_end] if b"\0" in name: raise Reject("EINTEGRITY", f"{point}.name-nul") return ( { "index": name_index, "name": name, "value": raw[name_end:value_end], }, total, ) def shared_entry(self, shared_id: int) -> dict: offset = (self.xattr_blkaddr << self.block_bits) + shared_id * 4 header = self.read(offset, 4, "shared.header") name_length, _, value_length = struct.unpack(" dict: size = inode["xattr_size"] if size < 12: raise Reject("EINTEGRITY", "ibody.header") if size == 12: raise Reject("EOPNOTSUPP", "ibody.header-only") raw = self.read(self.body_offset(inode), size, "ibody") shared_count = raw[4] header_size = 12 + shared_count * 4 if header_size > size: raise Reject("EINTEGRITY", "ibody.shared-count") shared_ids = [ struct.unpack_from(" bool: with self.lock: if amount > self.limit - self.resident: return False self.resident += amount return True def release(self, amount: int) -> None: with self.lock: if amount > self.resident: raise RuntimeError("cache budget underflow") self.resident -= amount class BodyCache: EMPTY = "EMPTY" INFLIGHT = "INFLIGHT" READY = "READY" FAILED = "FAILED" def __init__(self, spec: dict, budget: MountBudget): self.limit = spec["model"]["entry_body_limit_bytes"] self.budget = budget self.cv = threading.Condition() self.state = self.EMPTY self.body: dict | None = None self.error: Reject | None = None self.waiters = 0 self.loads = 0 self.charged = 0 self.closing = False self.wait_for_waiters = 0 def acquire(self, reader: Reader, inode: dict[str, int]) -> dict: owner = False reserved = False with self.cv: if self.closing: raise Reject("ENXIO", "cache.closing") if self.state == self.READY: if self.body is None: raise RuntimeError("READY cache has no body") return self.body if self.state == self.INFLIGHT: self.waiters += 1 try: while self.state == self.INFLIGHT: self.cv.wait() if self.state == self.READY: if self.body is None: raise RuntimeError("published cache has no body") return self.body if self.state != self.FAILED or self.error is None: raise RuntimeError("cache waiter has no typed result") raise Reject(self.error.errno_name, self.error.point) finally: self.waiters -= 1 if self.state == self.FAILED and self.waiters == 0: self.error = None self.state = self.EMPTY if self.waiters == 0: self.cv.notify_all() if self.state == self.FAILED: if self.waiters: return reader.load_body(inode, validate_shared=True) self.error = None self.state = self.EMPTY if inode["xattr_size"] <= self.limit: reserved = self.budget.reserve(inode["xattr_size"]) if not reserved: return reader.load_body(inode, validate_shared=True) self.state = self.INFLIGHT self.charged = inode["xattr_size"] owner = True if not owner: raise RuntimeError("cache claim lost owner state") try: if self.wait_for_waiters: deadline = time.monotonic() + 5 while True: with self.cv: if self.waiters >= self.wait_for_waiters: break if time.monotonic() >= deadline: raise RuntimeError("cache waiters did not reach owner barrier") time.sleep(0.001) body = reader.load_body(inode, validate_shared=True) with self.cv: self.loads += 1 self.body = body self.error = None self.state = self.READY self.cv.notify_all() return body except Reject as error: with self.cv: self.loads += 1 self.budget.release(self.charged) self.charged = 0 self.error = error self.state = self.FAILED if self.waiters == 0: self.error = None self.state = self.EMPTY self.cv.notify_all() raise def invalidate(self) -> None: with self.cv: while self.state == self.INFLIGHT or self.waiters: self.cv.wait() if self.state == self.READY: self.body = None self.budget.release(self.charged) self.charged = 0 self.error = None self.state = self.EMPTY def close(self) -> None: with self.cv: self.closing = True while self.state == self.INFLIGHT or self.waiters: self.cv.wait() if self.state == self.READY: self.body = None self.budget.release(self.charged) self.charged = 0 self.error = None self.state = self.EMPTY def body_for_operation( reader: Reader, inode: dict[str, int], variant: str, cache: BodyCache | None ) -> dict: if variant == "baseline": return reader.load_body(inode, validate_shared=False) if variant != "candidate" or cache is None: raise RuntimeError("invalid B19a oracle variant") return cache.acquire(reader, inode) def entries_for_body(reader: Reader, body: dict) -> list[dict]: entries = list(body["inline"]) entries.extend(reader.shared_entry(shared_id) for shared_id in body["shared_ids"]) return entries def getxattr( reader: Reader, inode: dict[str, int], name: bytes, variant: str, cache: BodyCache | None, ) -> bytes: body = body_for_operation(reader, inode, variant, cache) for entry in body["inline"]: if entry["index"] == 1 and entry["name"] == name: return bytes(entry["value"]) for shared_id in body["shared_ids"]: entry = reader.shared_entry(shared_id) if entry["index"] == 1 and entry["name"] == name: return bytes(entry["value"]) raise Reject("ENOATTR", "xattr.not-found") def listxattr( reader: Reader, inode: dict[str, int], variant: str, cache: BodyCache | None, ) -> list[bytes]: return [ bytes(entry["name"]) for entry in entries_for_body( reader, body_for_operation(reader, inode, variant, cache) ) if entry["index"] == 1 ] def expected_value(label: str, length: int) -> bytes: seed = (label + "|").encode("ascii") return (seed * (length // len(seed) + 1))[:length] def encode_dirblock(names: list[bytes], size: int = 128) -> bytes: first_name = len(names) * 12 payload = bytearray(size) cursor = first_name for index, name in enumerate(names): if cursor + len(name) > size: raise ValueError("synthetic directory block is too small") struct.pack_into(" dict[str, str]: try: function() except Reject as error: if error.errno_name != errno_name or error.point != point: raise SystemExit( f"oracle control expected {errno_name} at {point}, got {error}" ) return {"errno": error.errno_name, "point": error.point} raise SystemExit(f"oracle control unexpectedly passed: {errno_name} at {point}") def run_selftest(output: Path) -> dict: controls = {} first = Reader.parse_dirblock( encode_dirblock([b"alpha", b"boundary-last"], 96), 0, ) second = Reader.parse_dirblock( encode_dirblock([b"target", b"zeta"], 80), 1 ) Reader.validate_boundary(first, second) combined = first + second target_nid = Reader.resolve_entries(combined, b"target") matches = [entry for entry in combined if entry["name"] == b"target"] if ( len(matches) != 1 or matches[0]["block"] != 1 or int(matches[0]["nid"]) != target_nid ): raise SystemExit("target was not resolved after a block boundary") controls["target-after-boundary"] = "PASS" controls["short-final-block"] = "PASS" controls["duplicate-within-block"] = expect_reject( lambda: Reader.parse_dirblock(encode_dirblock([b"dup", b"dup"]), 0), "EINTEGRITY", "directory.order", ) left = Reader.parse_dirblock(encode_dirblock([b"alpha", b"dup"]), 0) right = Reader.parse_dirblock(encode_dirblock([b"dup", b"zeta"]), 1) controls["duplicate-across-blocks"] = expect_reject( lambda: Reader.validate_boundary(left, right), "EINTEGRITY", "directory.boundary-order", ) controls["missing-target"] = expect_reject( lambda: Reader.resolve_entries(combined, b"missing"), "ENOENT", "path.missing", ) duplicate_entries = combined + [{**combined[-1], "nid": 999, "name": b"target"}] controls["duplicate-resolution"] = expect_reject( lambda: Reader.resolve_entries(duplicate_entries, b"target"), "EINTEGRITY", "path.duplicate", ) backwards = Reader.parse_dirblock(encode_dirblock([b"aardvark", b"beta"]), 1) controls["backwards-boundary"] = expect_reject( lambda: Reader.validate_boundary(first, backwards), "EINTEGRITY", "directory.boundary-order", ) malformed = bytearray(encode_dirblock([b"alpha", b"bravo"])) struct.pack_into(" dict[str, object]: budget = MountBudget(spec["model"]["cache_budget_bytes"]) cache = BodyCache(spec, budget) if variant == "candidate" else None try: with Reader(image) as reader: inode = reader.resolve(spec["fixture"]["target"]) reader.stats.reset() if operation == "get-inline": value = getxattr(reader, inode, b"inline", variant, cache) return {"status": "PASS", "value_sha256": hashlib.sha256(value).hexdigest()} if operation == "get-shared": value = getxattr(reader, inode, b"shared", variant, cache) return {"status": "PASS", "value_sha256": hashlib.sha256(value).hexdigest()} if operation == "list-user": names = listxattr(reader, inode, variant, cache) return {"names": [name.decode("ascii") for name in names], "status": "PASS"} raise RuntimeError(f"unknown operation: {operation}") except Reject as error: return {"errno": spec["errno"].get(error.errno_name, -1), "point": error.point, "status": error.errno_name} finally: if cache is not None: cache.close() if budget.resident != 0: raise RuntimeError("operation cache budget leaked") def run_correctness(fixtures: Path, spec: dict, output: Path) -> dict: valid = fixtures / "valid.erofs" with Reader(valid) as reader: root = reader.inode(reader.root_nid) root_entries = reader.directory_entries(root) target_entries = [entry for entry in root_entries if entry["name"] == b"target.bin"] if len(target_entries) != 1 or int(target_entries[0]["block"]) == 0: raise SystemExit("real target did not resolve after the first block") if [entry["name"] for entry in root_entries] != sorted( entry["name"] for entry in root_entries ): raise SystemExit("real directory order is unstable") boundary = [] for left, right in zip(root_entries, root_entries[1:]): if left["block"] != right["block"]: boundary.append( { "left": bytes(left["name"]).decode("ascii"), "right": bytes(right["name"]).decode("ascii"), } ) inode = reader.resolve(spec["fixture"]["target"]) body = reader.load_body(inode, validate_shared=True) if not body["inline"] or not body["shared_ids"]: raise SystemExit("real target lacks inline/shared xattr coverage") directory = { "block_count": (root["size"] + reader.block_size - 1) // reader.block_size, "boundaries": boundary, "entry_count": len(root_entries), "target_block": target_entries[0]["block"], "target_entry": target_entries[0]["entry"], } expected_inline = expected_value( "inline-value-p15-022", spec["fixture"]["inline_value_bytes"] ) expected_shared = expected_value( "shared-value-p15-022", spec["fixture"]["shared_value_bytes"] ) cases = [] for variant in ("baseline", "candidate"): inline = operation_result(valid, spec, variant, "get-inline") shared = operation_result(valid, spec, variant, "get-shared") listed = operation_result(valid, spec, variant, "list-user") if inline.get("value_sha256") != hashlib.sha256(expected_inline).hexdigest(): raise SystemExit(f"{variant} inline value mismatch") if shared.get("value_sha256") != hashlib.sha256(expected_shared).hexdigest(): raise SystemExit(f"{variant} shared value mismatch") if sorted(listed.get("names", [])) != ["inline", "shared"]: raise SystemExit(f"{variant} list result mismatch: {listed}") cases.extend( [ {"id": "valid-inline", "variant": variant, "result": inline}, {"id": "valid-shared", "variant": variant, "result": shared}, {"id": "valid-list", "variant": variant, "result": listed}, ] ) damaged = [ ("corrupt-shared-count.erofs", "get-inline", "EINTEGRITY"), ("corrupt-shared-id.erofs", "get-shared", "EINTEGRITY"), ("corrupt-inline-name.erofs", "list-user", "EINTEGRITY"), ] for filename, operation, expected in damaged: for variant in ("baseline", "candidate"): result = operation_result(fixtures / filename, spec, variant, operation) if result["status"] != expected or int(result.get("errno", -1)) < 0: raise SystemExit(f"{filename} {variant} mismatch: {result}") cases.append({"id": filename, "variant": variant, "result": result}) budget = MountBudget(spec["model"]["cache_budget_bytes"]) cache = BodyCache(spec, budget) cache.wait_for_waiters = spec["concurrency"]["workers"] - 1 with Reader(valid) as reader: inode = reader.resolve(spec["fixture"]["target"]) barrier = threading.Barrier(spec["concurrency"]["workers"]) def worker(_: int) -> str: barrier.wait() value = getxattr(reader, inode, b"inline", "candidate", cache) return hashlib.sha256(value).hexdigest() with ThreadPoolExecutor(max_workers=spec["concurrency"]["workers"]) as executor: hashes = list(executor.map(worker, range(spec["concurrency"]["workers"]))) if len(set(hashes)) != 1 or cache.loads != 1 or cache.state != BodyCache.READY: raise SystemExit("candidate owner/waiter publication mismatch") first_charge = budget.resident cache.invalidate() if budget.resident != 0 or cache.state != BodyCache.EMPTY: raise SystemExit("candidate invalidation did not release cache") cache.wait_for_waiters = 0 getxattr(reader, inode, b"inline", "candidate", cache) if cache.loads != 2 or budget.resident != first_charge: raise SystemExit("candidate re-establishment mismatch") cache.close() if budget.resident != 0: raise SystemExit("candidate reclaim/close leaked budget") report = { "cases": cases, "concurrency": { "body_loads": 1, "completed": spec["concurrency"]["workers"], "workers": spec["concurrency"]["workers"], }, "directory": directory, "fixture_sha256": { path.name: sha256(path) for path in sorted(fixtures.glob("*.erofs")) }, "lifecycle": { "establish": "PASS", "hit": "PASS", "invalidate": "PASS", "reclaim": "PASS", "resident_after": budget.resident, }, "status": "PASS", } output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii") return report def run_sample( image: Path, spec: dict, variant: str, sample: int, output: Path ) -> dict: budget = MountBudget(spec["model"]["cache_budget_bytes"]) cache = BodyCache(spec, budget) if variant == "candidate" else None operations = spec["benchmark"]["operations"] with Reader(image) as reader: inode = reader.resolve(spec["fixture"]["target"]) def one_loop() -> None: for operation in operations: if operation == "get-inline": getxattr(reader, inode, b"inline", variant, cache) elif operation == "get-shared": getxattr(reader, inode, b"shared", variant, cache) elif operation == "list-user": listxattr(reader, inode, variant, cache) else: raise RuntimeError(f"unknown benchmark operation: {operation}") for _ in range(spec["benchmark"]["warmup_loops"]): one_loop() reader.stats.reset() started = time.monotonic_ns() for _ in range(spec["benchmark"]["loops_per_sample"]): one_loop() elapsed = time.monotonic_ns() - started reads = reader.stats.snapshot() loads = cache.loads if cache is not None else 0 resident_before_close = budget.resident if cache is not None: cache.close() if budget.resident != 0: raise SystemExit("benchmark cache budget leaked") report = { "cache_body_loads": loads, "cache_resident_before_close": resident_before_close, "elapsed_ns": elapsed, "fixture_sha256": sha256(image), "host": os.uname().sysname + " " + os.uname().release, "loops": spec["benchmark"]["loops_per_sample"], "operations": operations, "reads": reads, "sample": sample, "status": "PASS", "variant": variant, "warmup_loops": spec["benchmark"]["warmup_loops"], } output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii") print(json.dumps(report, sort_keys=True)) return report def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--spec", type=Path, required=True) subparsers = parser.add_subparsers(dest="command", required=True) selftest = subparsers.add_parser("selftest") selftest.add_argument("--output", type=Path, required=True) correctness = subparsers.add_parser("correctness") correctness.add_argument("--fixtures", type=Path, required=True) correctness.add_argument("--output", type=Path, required=True) sample = subparsers.add_parser("sample") sample.add_argument("--image", type=Path, required=True) sample.add_argument("--variant", choices=("baseline", "candidate"), required=True) sample.add_argument("--sample", type=int, required=True) sample.add_argument("--output", type=Path, required=True) args = parser.parse_args() spec = load_spec(args.spec) if args.command == "selftest": report = run_selftest(args.output) print(json.dumps(report, sort_keys=True)) elif args.command == "correctness": report = run_correctness(args.fixtures, spec, args.output) print(json.dumps(report, sort_keys=True)) else: run_sample(args.image, spec, args.variant, args.sample, args.output) return 0 if __name__ == "__main__": raise SystemExit(main())