#!/usr/bin/env python3 from __future__ import annotations import argparse import json from pathlib import Path import re import subprocess EINTEGRITY = "EINTEGRITY" PASS = "PASS" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--root", required=True, type=Path) parser.add_argument("--dut", required=True, type=Path) parser.add_argument("--baseline", required=True) parser.add_argument("--spec", required=True, type=Path) parser.add_argument("--report", required=True, type=Path) return parser.parse_args() def committed(root: Path, baseline: str, path: str) -> str: completed = subprocess.run( ["git", "-C", str(root), "show", f"{baseline}:{path}"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if completed.returncode != 0: raise SystemExit(f"cannot read B10 baseline {path}: {completed.stderr}") return completed.stdout def extract_function(source: str, name: str) -> str: match = re.search(r"^" + re.escape(name) + r"\s*\(", source, re.MULTILINE) if not match: raise SystemExit(f"missing function: {name}") name_line = source.rfind("\n", 0, match.start()) + 1 start = source.rfind("\n", 0, name_line - 1) + 1 brace = source.find("{", match.end()) if brace < 0: raise SystemExit(f"missing function body: {name}") depth = 0 state = "code" index = brace while index < len(source): char = source[index] following = source[index + 1] if index + 1 < len(source) else "" if state == "code": if char == "/" and following == "*": state = "block" index += 2 continue if char == "/" and following == "/": state = "line" index += 2 continue if char == '"': state = "string" elif char == "'": state = "character" elif char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: return source[start : index + 1] elif state == "block" and char == "*" and following == "/": state = "code" index += 2 continue elif state == "line" and char == "\n": state = "code" elif state in {"string", "character"}: if char == "\\": index += 2 continue if (state == "string" and char == '"') or ( state == "character" and char == "'" ): state = "code" index += 1 raise SystemExit(f"unterminated function: {name}") def require_order(source: str, markers: list[str], label: str) -> None: position = -1 for marker in markers: position = source.find(marker, position + 1) if position < 0: raise SystemExit(f"{label} is missing ordered marker: {marker}") def function_position(source: str, name: str) -> int: match = re.search(r"^" + re.escape(name) + r"\s*\(", source, re.MULTILINE) if not match: raise SystemExit(f"missing function position: {name}") return match.start() def source_checks(root: Path, dut: Path, baseline: str) -> dict[str, object]: src = dut / "src" current = { name: (src / name).read_text(encoding="utf-8") for name in ("dir.c", "namei.c", "internal.h", "erofs_vnops.c") } base = { name: committed(root, baseline, f"repo-pre-15/src/{name}") for name in current } if current["dir.c"] == base["dir.c"] or current["namei.c"] == base["namei.c"]: raise SystemExit("B10 did not change both declared directory sources") if current["internal.h"] != base["internal.h"]: raise SystemExit("B10 added directory-wide state outside its write set") if current["erofs_vnops.c"] != base["erofs_vnops.c"]: raise SystemExit("B10 changed the FreeBSD VOP adapter") completed = subprocess.run( [ "git", "-C", str(root), "diff", "--name-only", baseline, "--", "repo-pre-15/src", ], check=True, text=True, stdout=subprocess.PIPE, ) changed_sources = set(completed.stdout.splitlines()) expected_sources = { "repo-pre-15/src/dir.c", "repo-pre-15/src/namei.c", } if changed_sources != expected_sources: raise SystemExit(f"unexpected B10 source write set: {sorted(changed_sources)}") dir_source = current["dir.c"] namei_source = current["namei.c"] validator = extract_function(dir_source, "erofs_validate_dirblock") fill = extract_function(dir_source, "erofs_fill_dentries") readdir = extract_function(dir_source, "erofs_readdir_block") previous = extract_function(dir_source, "erofs_previous_dirname") read_block = extract_function(namei_source, "erofs_read_dirblock") neighbors = extract_function(namei_source, "erofs_validate_dirblock_neighbors") find_block = extract_function(namei_source, "erofs_find_target_block") namei = extract_function(namei_source, "erofs_namei") lookup = extract_function(namei_source, "erofs_lookup") require_order( validator, ["erofs_dirent_namelen", "erofs_dirname_order", "EINTEGRITY"], "block lexical validator", ) require_order( fill, ["erofs_dirent_name", "erofs_nid_is_valid", "bzero", "d_fileno", "erofs_uiodir"], "readdir publication boundary", ) require_order( readdir, [ "GENERIC_MINDIRSIZ", "(size_t)INT_MAX", "SIZE_MAX / sizeof(*cookiebuf)", "(int)cookie_count", ], "cookie clamp", ) if "uio->uio_resid / 8" in readdir or "MAX(1" in readdir: raise SystemExit("legacy unchecked cookie sizing remains") require_order( readdir, [ "erofs_validate_dirblock", "erofs_previous_dirname", "erofs_dirname_order", "erofs_fill_dentries", ], "linear readdir order validation", ) if previous.count("erofs_read_data") != 1 or previous.count("erofs_brelse") != 1: raise SystemExit("readdir predecessor ownership is not one acquire/release") require_order(read_block, ["erofs_read_data", "erofs_validate_dirblock"], "lookup block read") for marker in ("block - 1", "block + 1", "erofs_dirblock_order"): if marker not in neighbors: raise SystemExit(f"bounded neighbor validation is missing: {marker}") if find_block.count("while (head <= back)") != 1 or re.search(r"\bfor\s*\(", find_block): raise SystemExit("lookup is no longer a single binary block search") require_order( find_block, ["erofs_read_dirblock", "erofs_validate_dirblock_neighbors", "erofs_dirnamecmp"], "touched-block lookup validation", ) require_order(namei, ["erofs_nid_is_valid", "*nid = found_nid", "*d_type"], "lookup NID publication") require_order( lookup, ["erofs_namei", "(cnp->cn_flags & ISDOTDOT) == 0", "vn_vget_ino", "erofs_vget"], "FreeBSD self-NID lock boundary", ) if lookup.count("vn_vget_ino") != 1 or lookup.count("erofs_vget") != 1: raise SystemExit("FreeBSD dotdot/ordinary vnode acquisition changed shape") for marker in ("vref(dvp)", "cache_enter(dvp, NULL, cnp)", "cache_enter(dvp, vp, cnp)"): if marker not in lookup: raise SystemExit(f"FreeBSD lookup behavior is missing: {marker}") if re.search(r"return\s*\(\s*-E[A-Z0-9_]+", dir_source + namei_source): raise SystemExit("negative Linux errno entered B10") dir_order = [ "erofs_validate_dirblock", "erofs_fill_dentries", "erofs_readdir_block", ] if [function_position(dir_source, name) for name in dir_order] != sorted( function_position(dir_source, name) for name in dir_order ): raise SystemExit("dir core order does not retain validator-before-Linux-core shape") namei_order = [ "find_target_dirent", "erofs_find_target_block", "erofs_namei", "erofs_lookup", ] if [function_position(namei_source, name) for name in namei_order] != sorted( function_position(namei_source, name) for name in namei_order ): raise SystemExit("namei core order diverges from the Linux reference shape") return { "changed_sources": sorted(changed_sources), "dir_core_order": dir_order, "namei_core_order": namei_order, "full_directory_state_added": False, "positive_errno": True, "vfs_cache_lookup_preserved": "vfs_cache_lookup" in current["erofs_vnops.c"], } def validate_block(names: list[str]) -> None: if not names or any(not name for name in names): raise ValueError(EINTEGRITY) encoded = [name.encode("ascii") for name in names] if any(left >= right for left, right in zip(encoded, encoded[1:])): raise ValueError(EINTEGRITY) def validate_boundary(left: list[str], right: list[str]) -> None: if left[-1].encode("ascii") >= right[0].encode("ascii"): raise ValueError(EINTEGRITY) def lookup_model(blocks: list[list[str]], target: str) -> tuple[str, int, list[int]]: head = 0 back = len(blocks) - 1 candidate = None reads = 0 touched = [] target_bytes = target.encode("ascii") try: while head <= back: mid = head + (back - head) // 2 touched.append(mid) validate_block(blocks[mid]) reads += 1 if mid > 0: validate_block(blocks[mid - 1]) validate_boundary(blocks[mid - 1], blocks[mid]) reads += 1 if mid + 1 < len(blocks): validate_block(blocks[mid + 1]) validate_boundary(blocks[mid], blocks[mid + 1]) reads += 1 first = blocks[mid][0].encode("ascii") if target_bytes < first: back = mid - 1 continue candidate = mid if target_bytes == first: return PASS, reads, touched head = mid + 1 if candidate is None: return "ENOENT", reads, touched names = [name.encode("ascii") for name in blocks[candidate]] left = 1 right = len(names) - 1 while left <= right: mid = left + (right - left) // 2 if names[mid] == target_bytes: return PASS, reads, touched if names[mid] < target_bytes: left = mid + 1 else: right = mid - 1 return "ENOENT", reads, touched except ValueError: return EINTEGRITY, reads, touched def readdir_model(blocks: list[list[str]]) -> tuple[str, list[str]]: output = [] previous = None try: for block in blocks: validate_block(block) if previous is not None: validate_boundary(previous, block) output.extend(block) previous = block except ValueError: return EINTEGRITY, output return PASS, output def run_order_cases(spec: dict[str, object]) -> list[dict[str, object]]: results = [] for case in spec["order_cases"]: lookup_status, reads, touched = lookup_model(case["blocks"], case["target"]) readdir_status, raw = readdir_model(case["blocks"]) if lookup_status != case["expected_lookup"]: raise SystemExit(f"lookup order oracle mismatch: {case['id']}") if readdir_status != case["expected_readdir"]: raise SystemExit(f"readdir order oracle mismatch: {case['id']}") if "expected_raw" in case and raw != case["expected_raw"]: raise SystemExit(f"raw directory order changed: {case['id']}") results.append( { "id": case["id"], "lookup": lookup_status, "readdir": readdir_status, "lookup_reads": reads, "lookup_touched": touched, "raw_entries": raw if readdir_status == PASS else None, } ) return results def nid_is_valid(config: dict[str, object], case: dict[str, object]) -> bool: uint64_max = (1 << 64) - 1 metabox_bit = config["metabox_bit"] nid = case["nid"] low = nid & (metabox_bit - 1) if low > (uint64_max >> 5): return False offset = low << 5 in_metabox = bool(nid & metabox_bit) if in_metabox: if not case["metabox_feature"] or case["metabox_size"] is None: return False return offset <= case["metabox_size"] and config["compact_inode_size"] <= case["metabox_size"] - offset primary = config["primary"] if primary["blocks"] > (uint64_max >> primary["blkszbits"]): return False metadata = primary["meta_blkaddr"] << primary["blkszbits"] if offset > uint64_max - metadata: return False offset += metadata image_size = primary["blocks"] << primary["blkszbits"] compact = config["compact_inode_size"] return ( offset <= image_size and compact <= image_size - offset and offset <= primary["mediasize"] and compact <= primary["mediasize"] - offset ) def run_nid_cases(config: dict[str, object]) -> list[dict[str, object]]: results = [] for case in config["cases"]: valid = nid_is_valid(config, case) lookup = EINTEGRITY if not valid else PASS if valid and case["relation"] == "ordinary" and case["nid"] == case["current_nid"]: lookup = EINTEGRITY readdir = PASS if valid else EINTEGRITY if valid != case["expected_valid"] or lookup != case["expected_lookup"] or readdir != case["expected_readdir"]: raise SystemExit(f"NID oracle mismatch: {case['id']}") results.append({"id": case["id"], "valid": valid, "lookup": lookup, "readdir": readdir}) return results def run_cookie_cases(config: dict[str, object]) -> list[dict[str, int]]: results = [] allocation_limit = config["size_max"] // 8 for case in config["cases"]: count = 0 if case["resid"] > 0: count = case["resid"] // config["min_dirent_size"] count = min(count, config["int_max"], allocation_limit) if count != case["expected"]: raise SystemExit(f"cookie clamp oracle mismatch: {case['id']}") results.append({"id": case["id"], "resid": case["resid"], "cookies": count}) return results def complexity_result(config: dict[str, int]) -> dict[str, object]: blocks = config["directory_bytes"] // config["block_size"] if blocks != config["expected_blocks"]: raise SystemExit("TC153 sparse directory block denominator changed") max_iterations = blocks.bit_length() if max_iterations != config["max_binary_iterations"]: raise SystemExit("TC153 binary iteration bound mismatch") scenarios = [] for target in (0, blocks // 2, blocks - 1): head = 0 back = blocks - 1 iterations = 0 reads = 0 while head <= back: mid = head + (back - head) // 2 iterations += 1 reads += 1 + int(mid > 0) + int(mid + 1 < blocks) if mid == target: break if mid < target: head = mid + 1 else: back = mid - 1 if iterations > config["max_binary_iterations"] or reads > config["max_reads_with_neighbors"]: raise SystemExit("TC153 lookup exceeded the logarithmic neighbor-read bound") scenarios.append({"target_block": target, "iterations": iterations, "reads": reads}) return { "directory_bytes": config["directory_bytes"], "blocks": blocks, "bound": "O(log n) selected blocks plus two neighbors per selection", "scenarios": scenarios, } def main() -> None: args = parse_args() spec = json.loads(args.spec.read_text(encoding="ascii")) if spec.get("schema") != 1 or spec.get("batch") != "B10": raise SystemExit("invalid B10 fixture identity") if spec.get("candidates") != ["P15-012", "P15-054", "P15-055", "P15-078", "P15-080"]: raise SystemExit("B10 candidate list changed") report = { "schema": 1, "batch": "B10", "source": source_checks(args.root, args.dut, args.baseline), "order": run_order_cases(spec), "nid": run_nid_cases(spec["nid"]), "cookie": run_cookie_cases(spec["cookie"]), "complexity": complexity_result(spec["complexity"]), } args.report.write_text( json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="ascii", ) print(f"TC163-directory-order PASS {len(report['order'])} cases") print(f"TC173-readdir-boundaries PASS {len(report['nid']) + len(report['cookie'])} cases") print("TC153-large-directory-block-index PASS O(log n)+bounded-neighbors") if __name__ == "__main__": main()