#!/bin/sh set -eu : "${PRE15_DUT:?PRE15_DUT is required}" : "${PRE15_ROOT:?PRE15_ROOT is required}" : "${PRE15_RUN_DIR:?PRE15_RUN_DIR is required}" : "${PRE15_LIB_DIR:?PRE15_LIB_DIR is required}" . "$PRE15_LIB_DIR/runner.sh" artifacts=$PRE15_RUN_DIR/artifacts action=${1:-callgraph} shift || : case "$action" in callgraph|nm-allowlist) ;; *) pre15_fail_usage "unknown B31r action: $action" ;; esac for tool in git nm python3 sha256sum; do command -v "$tool" >/dev/null 2>&1 || \ pre15_infra_blocked "missing B31r host tool: $tool" done for source in data.c dir.c inode.c internal.h namei.c super.c zdata.c zmap.c; do pre15_record_fixture "b31r-$source" "$PRE15_DUT/src/$source" done pre15_record_fixture b31r-case \ "$PRE15_DUT/tests/pre15/cases/B31r-visibility.sh" mkdir -p "$artifacts" if test "$action" = callgraph; then test "$#" -eq 0 || pre15_fail_usage 'B31r callgraph takes no arguments' pre15_target_reached if ! python3 -B - "$PRE15_ROOT" "$PRE15_DUT" \ "$artifacts/B31r-callgraph.json" <<'PY' from __future__ import annotations from collections import Counter import hashlib import json from pathlib import Path import re import subprocess import sys root = Path(sys.argv[1]) dut = Path(sys.argv[2]) report_path = Path(sys.argv[3]) src = dut / "src" symbols = { "erofs_map_dev": "data.c", "erofs_map_blocks": "data.c", "erofs_dirent_namelen": "dir.c", "erofs_iloc": "inode.c", } expected_occurrences = { "erofs_map_dev": {"data.c": 2}, "erofs_map_blocks": {"data.c": 3, "super.c": 1, "zdata.c": 1}, "erofs_dirent_namelen": {"dir.c": 3, "namei.c": 1}, "erofs_iloc": {"inode.c": 3}, } expected_cross_tu = { "erofs_map_dev": [], "erofs_map_blocks": ["super.c", "zdata.c"], "erofs_dirent_namelen": ["namei.c"], "erofs_iloc": [], } introduced_by = { ("erofs_map_blocks", "super.c"): "55c609db", ("erofs_map_blocks", "zdata.c"): "55c609db", ("erofs_dirent_namelen", "namei.c"): "c7508502", } def strip_noncode(source: str) -> str: output = list(source) state = "code" index = 0 while index < len(source): char = source[index] following = source[index + 1] if index + 1 < len(source) else "" if state == "code": if char == "/" and following == "*": output[index] = output[index + 1] = " " state = "block" index += 2 continue if char == "/" and following == "/": output[index] = output[index + 1] = " " state = "line" index += 2 continue if char == '"': output[index] = " " state = "string" elif char == "'": output[index] = " " state = "character" elif state == "block": if char == "*" and following == "/": output[index] = output[index + 1] = " " state = "code" index += 2 continue if char != "\n": output[index] = " " elif state == "line": if char == "\n": state = "code" else: output[index] = " " else: if char == "\\" and following: output[index] = output[index + 1] = " " index += 2 continue if (state == "string" and char == '"') or ( state == "character" and char == "'" ): state = "code" if char != "\n": output[index] = " " index += 1 if state == "block": raise SystemExit("unterminated block comment") return "".join(output) sources = { path.name: strip_noncode(path.read_text(encoding="utf-8")) for path in sorted(src.glob("*.c")) } locations: dict[str, list[dict[str, object]]] = {} cross_tu: dict[str, list[str]] = {} history: dict[str, str] = {} for symbol, owner in symbols.items(): pattern = re.compile(r"\b" + re.escape(symbol) + r"\s*\(") sites = [] counts = Counter() for filename, source in sources.items(): for match in pattern.finditer(source): line = source.count("\n", 0, match.start()) + 1 sites.append({"file": filename, "line": line}) counts[filename] += 1 actual_counts = dict(sorted(counts.items())) if actual_counts != expected_occurrences[symbol]: raise SystemExit( f"{symbol} call-token inventory changed: {actual_counts!r}" ) consumers = sorted(filename for filename in counts if filename != owner) if consumers != expected_cross_tu[symbol]: raise SystemExit(f"{symbol} cross-TU consumers changed: {consumers!r}") locations[symbol] = sites cross_tu[symbol] = consumers for filename in consumers: for site in sites: if site["file"] != filename: continue blamed = subprocess.run( [ "git", "-C", str(root), "blame", "--line-porcelain", f"-L{site['line']},{site['line']}", "--", f"repo-pre-15/src/{filename}", ], check=True, text=True, stdout=subprocess.PIPE, ).stdout.splitlines()[0].split()[0] expected = introduced_by[(symbol, filename)] if not blamed.startswith(expected): raise SystemExit( f"{symbol} {filename} introducer changed: {blamed}" ) history[f"{symbol}:{filename}"] = blamed internal = (src / "internal.h").read_text(encoding="utf-8") for symbol in symbols: prototype_count = len( re.findall(r"\b" + re.escape(symbol) + r"\s*\(", internal) ) expected = 1 if symbol in {"erofs_map_blocks", "erofs_dirent_namelen"} else 0 if prototype_count != expected: raise SystemExit( f"{symbol} prototype count changed: {prototype_count}, expected {expected}" ) data = (src / "data.c").read_text(encoding="utf-8") inode = (src / "inode.c").read_text(encoding="utf-8") if not re.search(r"\bstatic\s+int\s+erofs_map_dev\s*\(", data): raise SystemExit("erofs_map_dev is not static in data.c") if not re.search(r"\bstatic\s+erofs_off_t\s+erofs_iloc\s*\(", inode): raise SystemExit("erofs_iloc is not static in inode.c") all_source = "\n".join( path.read_text(encoding="utf-8") for path in sorted(src.glob("*.[ch]")) ) mtime_helper_invocations = all_source.count( "EROFS_FEATURE_FUNCS(mtime, compat, COMPAT_MTIME)" ) mtime_explicit_references = len(re.findall(r"\berofs_sb_has_mtime\b", all_source)) if mtime_helper_invocations != 0 or mtime_explicit_references != 0: raise SystemExit( "mtime helper inventory changed: " f"generator={mtime_helper_invocations} explicit={mtime_explicit_references}" ) erofs_fs = (src / "erofs_fs.h").read_text(encoding="utf-8") super_source = (src / "super.c").read_text(encoding="utf-8") timestamp_anchors = { "ondisk_constant": "#define EROFS_FEATURE_COMPAT_MTIME" in erofs_fs, "compact_checked_add": "__builtin_add_overflow(sbi->epoch," in inode, "extended_timestamp": "(int64_t)le64toh(die->i_mtime)" in inode, "epoch_decode": "sbi->epoch = (int64_t)le64toh(dsb->epoch);" in super_source, "fixed_nsec_decode": "sbi->fixed_nsec = le32toh(dsb->fixed_nsec);" in super_source, } if not all(timestamp_anchors.values()): raise SystemExit(f"timestamp path changed: {timestamp_anchors!r}") source_hashes = {} for path in sorted(src.glob("*.[ch]")): source_hashes[path.relative_to(dut).as_posix()] = hashlib.sha256( path.read_bytes() ).hexdigest() report = { "decision": "PASS", "freebsd_call_tokens": locations, "freebsd_cross_tu_consumers": cross_tu, "introduced_by": history, "linkage": { "erofs_map_dev": "static", "erofs_map_blocks": "external", "erofs_dirent_namelen": "external", "erofs_iloc": "static", }, "internal_prototypes": { symbol: len(re.findall(r"\b" + re.escape(symbol) + r"\s*\(", internal)) for symbol in symbols }, "mtime_helper": { "generator_invocations": mtime_helper_invocations, "explicit_references": mtime_explicit_references, }, "timestamp_anchors": timestamp_anchors, "source_hashes": source_hashes, } report_path.write_text( json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii" ) (report_path.parent / "B31r-source-sha256.txt").write_text( "".join(f"{digest} {path}\n" for path, digest in source_hashes.items()), encoding="ascii", ) PY then pre15_dut_fail 'B31r independent callgraph audit failed' fi printf '%s\n' \ 'B31r independent cross-TU callgraph PASS' \ 'erofs_map_dev and erofs_iloc are same-TU and static' \ 'erofs_map_blocks and erofs_dirent_namelen retain external linkage' \ 'erofs_sb_has_mtime helper is absent; timestamp anchors remain' \ 'source hashes and exact consumers recorded in B31r-callgraph.json' exit 0 fi test "$#" -eq 2 || \ pre15_fail_usage 'B31r nm-allowlist requires zstdio0 and zstdio1 modules' for module in "$@"; do pre15_record_fixture "b31r-module-$module" "$module" done pre15_target_reached for config in 0 1; do case "$config" in 0) module=$1 ;; 1) module=$2 ;; esac nm -an "$module" >"$artifacts/B31r-zstdio$config-nm-an.txt" awk '$NF ~ /^(erofs_map_dev|erofs_map_blocks|erofs_dirent_namelen|erofs_iloc|erofs_sb_has_mtime)$/' \ "$artifacts/B31r-zstdio$config-nm-an.txt" \ >"$artifacts/B31r-zstdio$config-target-bindings.txt" nm -g --defined-only "$module" | awk '{ print $NF }' | LC_ALL=C sort \ >"$artifacts/B31r-zstdio$config-global-names.txt" nm -u "$module" | awk '{ print $NF }' | LC_ALL=C sort \ >"$artifacts/B31r-zstdio$config-undefined-names.txt" for symbol in erofs_map_blocks erofs_dirent_namelen; do if ! awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \ "$artifacts/B31r-zstdio$config-global-names.txt"; then pre15_dut_fail "B31r zstdio$config lost required global $symbol" fi done for symbol in erofs_map_dev erofs_iloc erofs_sb_has_mtime; do if awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \ "$artifacts/B31r-zstdio$config-global-names.txt" || \ awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \ "$artifacts/B31r-zstdio$config-undefined-names.txt"; then pre15_dut_fail "B31r zstdio$config unexpectedly exposes $symbol" fi done for symbol in erofs_map_dev erofs_iloc; do if ! awk -v symbol="$symbol" \ '$NF == symbol && $(NF - 1) !~ /^[a-z]$/ { bad = 1 } END { exit bad }' \ "$artifacts/B31r-zstdio$config-nm-an.txt"; then pre15_dut_fail "B31r zstdio$config has non-local binding for $symbol" fi done if awk '$NF == "erofs_sb_has_mtime" { found = 1 } END { exit !found }' \ "$artifacts/B31r-zstdio$config-nm-an.txt"; then pre15_dut_fail "B31r zstdio$config emits erofs_sb_has_mtime" fi done if ! diff -u "$artifacts/B31r-zstdio0-global-names.txt" \ "$artifacts/B31r-zstdio1-global-names.txt" \ >"$artifacts/B31r-config-global.diff"; then pre15_dut_fail 'B31r defined globals differ by ZSTDIO configuration' fi printf '%s\n' \ 'B31r nm allowlist PASS' \ 'cross-TU symbols remain global in both configurations' \ 'same-TU symbols are absent or locally bound and never global/undefined' \ 'erofs_sb_has_mtime is absent from all symbol bindings' \ 'zstdio0/zstdio1 defined-global sets match'