Files
erofs-freebsd-out-tree/tests/pre15/fixtures/B10-directory-oracle.py
T
2026-08-18 09:20:44 +02:00

465 lines
17 KiB
Python
Executable File

#!/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()