chore(phase-2): close archive loader investigation

This commit is contained in:
Christopher Williams
2026-09-23 17:46:32 -04:00
parent 1dd720d998
commit cb87942249
10 changed files with 942 additions and 4 deletions
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Read-only, opaque structural feature probes for extracted disc files.
The probe validates a manifest-tracked extraction root before reading fixed-size
prefixes. Successful reports use deterministic opaque candidate identifiers and
never emit source paths or raw source bytes. It intentionally reports heuristics,
not file-format conclusions.
"""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import dataclass
import hashlib
import math
from pathlib import Path
import re
import sys
from typing import Sequence
MANIFEST_HEADER = "path\tlba\textents\tsize\tsha1"
SAFE_COMPONENT = re.compile(r"[A-Za-z0-9._;-]+\Z")
ISO_VERSION_SUFFIX = re.compile(r";[0-9]+\Z")
SHA1_PATTERN = re.compile(r"[0-9a-f]{40}\Z")
MAX_PREFIX_SIZE = 1024 * 1024
class ProbeValidationError(ValueError):
"""Raised when probe input cannot safely support a structural survey."""
@dataclass(frozen=True)
class ManifestEntry:
"""One validated manifest row, retained only for local probe selection."""
path: str
lba: int
size: int
sha1: str
@dataclass(frozen=True)
class CandidateFeatures:
"""Non-content structural measurements for one opaque candidate."""
identifier: str
size: int
prefix_size: int
prefix_sha1: str
entropy_milli: int
zero_bytes: int
monotonic_u32_run_count: int
@dataclass(frozen=True)
class PrefixFeatureReport:
"""A deterministic, opaque fixed-prefix feature report."""
extension: str
candidate_count: int
common_prefix_span: int
constant_positions: int
candidates: tuple[CandidateFeatures, ...]
def _validate_manifest_path(path: str, context: str) -> None:
if not path:
raise ProbeValidationError(f"{context}: empty path")
for component in path.split("/"):
if not component:
raise ProbeValidationError(f"{context}: empty path component")
if component in {".", ".."}:
raise ProbeValidationError(f"{context}: unsafe path component")
try:
component.encode("ascii")
except UnicodeEncodeError as exc:
raise ProbeValidationError(f"{context}: non-ASCII path component") from exc
if SAFE_COMPONENT.fullmatch(component) is None:
raise ProbeValidationError(f"{context}: unsafe path component")
def _parse_extents(field: str, context: str) -> tuple[tuple[int, int], ...]:
if not field:
raise ProbeValidationError(f"{context}: empty extent field")
extents: list[tuple[int, int]] = []
for encoded_extent in field.split(","):
lba_text, separator, size_text = encoded_extent.partition(":")
if not separator or ":" in size_text:
raise ProbeValidationError(f"{context}: malformed extent")
if not lba_text.isdecimal() or not size_text.isdecimal():
raise ProbeValidationError(f"{context}: non-decimal extent")
extents.append((int(lba_text), int(size_text)))
return tuple(extents)
def read_manifest(manifest_path: str | Path) -> tuple[ManifestEntry, ...]:
"""Read a strict extraction manifest without returning hashes or source data."""
manifest = Path(manifest_path)
if manifest.is_symlink():
raise ProbeValidationError("manifest must not be a symbolic link")
try:
text = manifest.read_text(encoding="ascii")
except UnicodeDecodeError as exc:
raise ProbeValidationError("manifest is not ASCII") from exc
except OSError as exc:
raise ProbeValidationError(f"cannot read manifest: {exc}") from exc
lines = text.splitlines()
if not lines or lines[0] != MANIFEST_HEADER:
raise ProbeValidationError("manifest has an unexpected header")
entries: list[ManifestEntry] = []
seen_paths: set[str] = set()
for line_number, line in enumerate(lines[1:], start=2):
fields = line.split("\t")
if len(fields) != 5:
raise ProbeValidationError(f"manifest line {line_number}: expected five fields")
path, lba_text, extent_text, size_text, sha1 = fields
context = f"manifest line {line_number}"
_validate_manifest_path(path, context)
if path in seen_paths:
raise ProbeValidationError(f"{context}: duplicate path")
seen_paths.add(path)
if not lba_text.isdecimal() or not size_text.isdecimal():
raise ProbeValidationError(f"{context}: non-decimal LBA or size")
if SHA1_PATTERN.fullmatch(sha1) is None:
raise ProbeValidationError(f"{context}: malformed SHA-1")
extents = _parse_extents(extent_text, context)
lba = int(lba_text)
size = int(size_text)
if extents[0][0] != lba:
raise ProbeValidationError(f"{context}: first extent does not match LBA")
if sum(extent_size for _extent_lba, extent_size in extents) != size:
raise ProbeValidationError(f"{context}: extents do not sum to size")
entries.append(ManifestEntry(path=path, lba=lba, size=size, sha1=sha1))
return tuple(entries)
def _normalize_extension(extension: str) -> str:
normalized = extension.strip()
if normalized.startswith("."):
normalized = normalized[1:]
if not normalized or re.fullmatch(r"[A-Za-z0-9]+", normalized) is None:
raise ProbeValidationError("extension must contain only ASCII letters and digits")
return normalized.upper()
def _entry_extension(path: str) -> str | None:
leaf = ISO_VERSION_SUFFIX.sub("", path.rsplit("/", 1)[-1])
if "." not in leaf:
return None
return leaf.rsplit(".", 1)[1].upper()
def _resolve_root(root_path: str | Path) -> Path:
root = Path(root_path)
if root.is_symlink():
raise ProbeValidationError("extraction root must not be a symbolic link")
try:
resolved = root.resolve(strict=True)
except OSError as exc:
raise ProbeValidationError(f"cannot resolve extraction root: {exc}") from exc
if not resolved.is_dir():
raise ProbeValidationError("extraction root is not a directory")
return resolved
def _resolve_candidate(root: Path, entry: ManifestEntry) -> Path:
candidate = root / entry.path
if candidate.is_symlink():
raise ProbeValidationError("candidate must not be a symbolic link")
try:
resolved = candidate.resolve(strict=True)
except OSError as exc:
raise ProbeValidationError(f"cannot resolve candidate: {exc}") from exc
try:
resolved.relative_to(root)
except ValueError as exc:
raise ProbeValidationError("candidate escapes extraction root") from exc
if not resolved.is_file():
raise ProbeValidationError("candidate is not a regular file")
try:
actual_size = resolved.stat().st_size
except OSError as exc:
raise ProbeValidationError(f"cannot stat candidate: {exc}") from exc
if actual_size != entry.size:
raise ProbeValidationError("candidate size does not match manifest")
return resolved
def _read_verified_prefix(candidate: Path, entry: ManifestEntry, prefix_size: int) -> bytes:
"""Read a bounded prefix while streaming a complete manifest-integrity check."""
digest = hashlib.sha1()
prefix = bytearray()
try:
with candidate.open("rb") as source:
while chunk := source.read(64 * 1024):
digest.update(chunk)
remaining = prefix_size - len(prefix)
if remaining > 0:
prefix.extend(chunk[:remaining])
except OSError as exc:
raise ProbeValidationError(f"cannot read candidate: {exc}") from exc
if digest.hexdigest() != entry.sha1:
raise ProbeValidationError("candidate SHA-1 does not match manifest")
return bytes(prefix)
def _entropy_milli(data: bytes) -> int:
if not data:
return 0
counts = Counter(data)
entropy = -sum(
(count / len(data)) * math.log2(count / len(data)) for count in counts.values()
)
return round(entropy * 1000)
def _monotonic_u32_run_count(prefix: bytes, file_size: int) -> int:
"""Count an intentionally generic offset-table heuristic in one prefix."""
count = 0
for offset in range(0, len(prefix) - 15, 4):
words = tuple(
int.from_bytes(prefix[offset + index : offset + index + 4], "little")
for index in range(0, 16, 4)
)
if all(word < file_size for word in words) and words == tuple(sorted(words)):
count += 1
return count
def probe_prefix_features(
manifest_path: str | Path,
root_path: str | Path,
*,
extension: str,
prefix_size: int = 4096,
) -> PrefixFeatureReport:
"""Measure opaque fixed-prefix features for one manifest-selected extension."""
if not 1 <= prefix_size <= MAX_PREFIX_SIZE:
raise ProbeValidationError(
f"prefix size must be between 1 and {MAX_PREFIX_SIZE} bytes"
)
normalized_extension = _normalize_extension(extension)
root = _resolve_root(root_path)
entries = sorted(
(entry for entry in read_manifest(manifest_path) if _entry_extension(entry.path) == normalized_extension),
key=lambda entry: entry.path,
)
if not entries:
raise ProbeValidationError("manifest has no candidates for the requested extension")
prefixes: list[bytes] = []
candidates: list[CandidateFeatures] = []
for index, entry in enumerate(entries, start=1):
candidate = _resolve_candidate(root, entry)
prefix = _read_verified_prefix(candidate, entry, prefix_size)
prefixes.append(prefix)
candidates.append(
CandidateFeatures(
identifier=f"C{index:03d}",
size=entry.size,
prefix_size=len(prefix),
prefix_sha1=hashlib.sha1(prefix).hexdigest(),
entropy_milli=_entropy_milli(prefix),
zero_bytes=prefix.count(0),
monotonic_u32_run_count=_monotonic_u32_run_count(prefix, entry.size),
)
)
common_prefix_span = min(len(prefix) for prefix in prefixes)
constant_positions = sum(
all(prefix[position] == prefixes[0][position] for prefix in prefixes)
for position in range(common_prefix_span)
)
return PrefixFeatureReport(
extension=normalized_extension,
candidate_count=len(candidates),
common_prefix_span=common_prefix_span,
constant_positions=constant_positions,
candidates=tuple(candidates),
)
def format_prefix_features(report: PrefixFeatureReport) -> str:
"""Format a deterministic report without candidate paths or raw bytes."""
lines = [
"format\tsf3_probe-prefix-features-v1",
f"candidate_count\t{report.candidate_count}",
f"common_prefix_span\t{report.common_prefix_span}",
f"constant_positions_0_common_prefix_span\t{report.constant_positions}",
]
for candidate in report.candidates:
lines.extend(
[
f"candidate\t{candidate.identifier}\tsize\t{candidate.size}",
f"candidate\t{candidate.identifier}\tprefix_size\t{candidate.prefix_size}",
f"candidate\t{candidate.identifier}\tprefix_sha1\t{candidate.prefix_sha1}",
f"candidate\t{candidate.identifier}\tentropy_milli\t{candidate.entropy_milli}",
f"candidate\t{candidate.identifier}\tzero_bytes\t{candidate.zero_bytes}",
"candidate\t"
f"{candidate.identifier}\tmonotonic_u32_run_count\t"
f"{candidate.monotonic_u32_run_count}",
]
)
return "\n".join(lines)
def _positive_prefix_size(value: str) -> int:
try:
prefix_size = int(value, 10)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer") from exc
if not 1 <= prefix_size <= MAX_PREFIX_SIZE:
raise argparse.ArgumentTypeError(
f"must be between 1 and {MAX_PREFIX_SIZE}"
)
return prefix_size
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Produce opaque, read-only structural feature reports for manifest-tracked files."
)
commands = parser.add_subparsers(dest="command", required=True)
prefix_parser = commands.add_parser(
"prefix-features",
help="measure fixed-prefix heuristics for one manifest-selected extension",
)
prefix_parser.add_argument("manifest", type=Path, help="validated extraction manifest")
prefix_parser.add_argument("root", type=Path, help="matching extraction root")
prefix_parser.add_argument(
"--extension",
required=True,
help="extension class to select, without an ISO version suffix",
)
prefix_parser.add_argument(
"--prefix-size",
type=_positive_prefix_size,
default=4096,
help="bytes read from each candidate (default: 4096)",
)
args = parser.parse_args(argv)
try:
report = probe_prefix_features(
args.manifest,
args.root,
extension=args.extension,
prefix_size=args.prefix_size,
)
except ProbeValidationError as exc:
print(f"sf3_probe: error: {exc}", file=sys.stderr)
return 2
print(format_prefix_features(report))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+253
View File
@@ -0,0 +1,253 @@
"""Synthetic-only tests for opaque manifest-backed structural probing.
Fixtures are created in temporary directories. They never inspect the local disc,
extracted game files, or any game-derived probe report.
"""
from __future__ import annotations
import hashlib
import importlib.machinery
import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
TOOL_PATH = Path(__file__).resolve().parents[1] / "sf3_probe"
def _load_tool() -> object:
loader = importlib.machinery.SourceFileLoader("sf3_probe_under_test", str(TOOL_PATH))
spec = importlib.util.spec_from_loader(loader.name, loader)
if spec is None:
raise RuntimeError("could not create an import specification for sf3_probe")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
return module
sf3_probe = _load_tool()
def _manifest_row(
path: str,
lba: int,
contents: bytes,
*,
size: int | None = None,
sha1: str | None = None,
) -> str:
declared_size = len(contents) if size is None else size
digest = hashlib.sha1(contents).hexdigest() if sha1 is None else sha1
return f"{path}\t{lba}\t{lba}:{declared_size}\t{declared_size}\t{digest}\n"
def _write_manifest(root: Path, rows: list[tuple[str, bytes]]) -> Path:
root.mkdir(parents=True, exist_ok=True)
manifest_rows = [sf3_probe.MANIFEST_HEADER + "\n"]
for index, (relative_path, contents) in enumerate(rows, start=1):
output = root / relative_path
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(contents)
manifest_rows.append(_manifest_row(relative_path, 100 + index, contents))
manifest = root / "MANIFEST.tsv"
manifest.write_text("".join(manifest_rows), encoding="ascii")
return manifest
class Sf3ProbeTests(unittest.TestCase):
def temporary_root(self) -> Path:
temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(temporary_directory.cleanup)
return Path(temporary_directory.name) / "extracted"
def test_prefix_features_are_deterministic_and_opaque(self) -> None:
root = self.temporary_root()
alpha = b"\x04\x00\x00\x00" + b"ALPHA-SYNTHETIC" + b"\x00" * 20
beta = b"\x04\x00\x00\x00" + b"BETA-SYNTHETIC!" + b"\x01" * 20
manifest = _write_manifest(
root,
[
("NEST/ALPHA.HOG;1", alpha),
("BETA.HOG;1", beta),
("IGNORE.DAT;1", b"not-selected"),
],
)
first = sf3_probe.probe_prefix_features(
manifest, root, extension="HOG", prefix_size=32
)
second = sf3_probe.probe_prefix_features(
manifest, root, extension=".hog", prefix_size=32
)
first_text = sf3_probe.format_prefix_features(first)
self.assertEqual(first, second)
self.assertEqual(first.candidate_count, 2)
self.assertEqual(first.common_prefix_span, 32)
self.assertEqual(first.constant_positions, 4)
self.assertEqual([candidate.identifier for candidate in first.candidates], ["C001", "C002"])
self.assertEqual(first_text, sf3_probe.format_prefix_features(second))
self.assertNotIn("NEST", first_text)
self.assertNotIn("ALPHA", first_text)
self.assertNotIn("BETA", first_text)
self.assertNotIn("SYNTHETIC", first_text)
self.assertNotIn("IGNORE", first_text)
def test_selects_extension_after_iso_version_suffix(self) -> None:
root = self.temporary_root()
manifest = _write_manifest(
root,
[
("FIRST.HOG;1", b"A" * 32),
("SECOND.hOg;99", b"B" * 32),
("THIRD.HOGX;1", b"C" * 32),
],
)
report = sf3_probe.probe_prefix_features(
manifest, root, extension="hog", prefix_size=16
)
self.assertEqual(report.candidate_count, 2)
self.assertEqual([candidate.prefix_size for candidate in report.candidates], [16, 16])
def test_counts_generic_monotonic_u32_runs_without_format_claim(self) -> None:
root = self.temporary_root()
words = b"".join(value.to_bytes(4, "little") for value in (4, 8, 12, 16, 20))
manifest = _write_manifest(root, [("TABLE.HOG;1", words + b"x" * 64)])
report = sf3_probe.probe_prefix_features(
manifest, root, extension="HOG", prefix_size=32
)
self.assertGreaterEqual(report.candidates[0].monotonic_u32_run_count, 2)
self.assertEqual(report.candidates[0].zero_bytes, 15)
def test_rejects_manifest_size_mismatch(self) -> None:
root = self.temporary_root()
contents = b"mismatch"
output = root / "BAD.HOG;1"
root.mkdir(parents=True)
output.write_bytes(contents)
manifest = root / "MANIFEST.tsv"
manifest.write_text(
sf3_probe.MANIFEST_HEADER
+ "\n"
+ _manifest_row("BAD.HOG;1", 101, contents, size=len(contents) + 1),
encoding="ascii",
)
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "size does not match"):
sf3_probe.probe_prefix_features(manifest, root, extension="HOG")
def test_rejects_manifest_sha1_mismatch(self) -> None:
root = self.temporary_root()
contents = b"digest-mismatch"
root.mkdir(parents=True)
(root / "BAD.HOG;1").write_bytes(contents)
manifest = root / "MANIFEST.tsv"
manifest.write_text(
sf3_probe.MANIFEST_HEADER
+ "\n"
+ _manifest_row("BAD.HOG;1", 101, contents, sha1="0" * 40),
encoding="ascii",
)
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "SHA-1 does not match"):
sf3_probe.probe_prefix_features(manifest, root, extension="HOG")
def test_rejects_unsafe_manifest_path(self) -> None:
root = self.temporary_root()
root.mkdir(parents=True)
contents = b"unsafe"
manifest = root / "MANIFEST.tsv"
manifest.write_text(
sf3_probe.MANIFEST_HEADER
+ "\n"
+ _manifest_row("../ESCAPE.HOG;1", 101, contents),
encoding="ascii",
)
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "unsafe path component"):
sf3_probe.probe_prefix_features(manifest, root, extension="HOG")
def test_rejects_symbolic_link_candidate(self) -> None:
root = self.temporary_root()
root.mkdir(parents=True)
target = root / "TARGET.BIN;1"
contents = b"target-data"
target.write_bytes(contents)
link = root / "LINK.HOG;1"
link.symlink_to(target.name)
manifest = root / "MANIFEST.tsv"
manifest.write_text(
sf3_probe.MANIFEST_HEADER + "\n" + _manifest_row(link.name, 101, contents),
encoding="ascii",
)
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "symbolic link"):
sf3_probe.probe_prefix_features(manifest, root, extension="HOG")
def test_rejects_absent_extension_class_and_invalid_prefix_size(self) -> None:
root = self.temporary_root()
manifest = _write_manifest(root, [("ONLY.DAT;1", b"data")])
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "no candidates"):
sf3_probe.probe_prefix_features(manifest, root, extension="HOG")
with self.assertRaisesRegex(sf3_probe.ProbeValidationError, "prefix size"):
sf3_probe.probe_prefix_features(manifest, root, extension="DAT", prefix_size=0)
def test_cli_reports_opaque_features_and_refuses_invalid_input(self) -> None:
root = self.temporary_root()
manifest = _write_manifest(root, [("CLI.HOG;1", b"CLI-SYNTHETIC" + b"\x00" * 20)])
completed = subprocess.run(
[
sys.executable,
str(TOOL_PATH),
"prefix-features",
str(manifest),
str(root),
"--extension",
"HOG",
"--prefix-size",
"16",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn("format\tsf3_probe-prefix-features-v1", completed.stdout)
self.assertNotIn("CLI.HOG", completed.stdout)
self.assertNotIn("SYNTHETIC", completed.stdout)
self.assertEqual(completed.stderr, "")
invalid = subprocess.run(
[
sys.executable,
str(TOOL_PATH),
"prefix-features",
str(manifest),
str(root),
"--extension",
"HOG!",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(invalid.returncode, 2)
self.assertIn("sf3_probe: error:", invalid.stderr)
self.assertEqual(invalid.stdout, "")
if __name__ == "__main__":
unittest.main()