diff --git a/tools/gear-ownership.py b/tools/gear-ownership.py index fa7ec6d..5ce0ff8 100644 --- a/tools/gear-ownership.py +++ b/tools/gear-ownership.py @@ -3,6 +3,7 @@ import argparse import base64 import copy +from datetime import datetime, timezone from decimal import Decimal import hashlib import json @@ -16,6 +17,11 @@ import yaml +LEGACY_EMPTY_PRODUCERS = {"d864c30aa6c01453e38bc2539d0ccea889b846aa47e9050982026e894a2e8247": "Magic 0.5.2"} +LEGACY_EMPTY_CANONICAL = b"stations: {}\n" +LEGACY_EMPTY_STAGING = ".gear-legacy-empty-" +LEGACY_EMPTY_HISTORY = ("gear-ownership", "gear-returns") + class GearLoader(yaml.SafeLoader): def construct_mapping(self, node, deep=False): @@ -472,6 +478,155 @@ def backup_gear(root, backup): return original +def _legacy_empty_read(path, label): + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise ValueError(label + " must be a regular nonsymlink file") + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(label + " must be a regular file") + chunks = [] + while chunk := os.read(descriptor, 65536): + chunks.append(chunk) + after = os.fstat(descriptor) + fields = ("st_dev", "st_ino", "st_size", "st_mode", "st_uid", "st_gid", "st_mtime_ns", "st_ctime_ns", "st_nlink") + if any(getattr(before, field) != getattr(after, field) for field in fields): + raise ValueError(label + " changed while being read") + return after, b"".join(chunks) + finally: + os.close(descriptor) + + +def _legacy_empty_state(root, legacy_jar, own_staging=None): + root = Path(root) + if not root.is_absolute() or Path(os.path.realpath(root)) != root or not stat.S_ISDIR(os.lstat(root).st_mode): + raise ValueError("--data must be an absolute, symlink-free directory") + if legacy_jar is None: + raise ValueError("legacy-empty import requires --legacy-jar") + _, jar_bytes = _legacy_empty_read(Path(legacy_jar), "legacy JAR") + producer = digest(jar_bytes) + if producer not in LEGACY_EMPTY_PRODUCERS: + raise ValueError("legacy JAR is not a reviewed empty-save producer") + for name in sorted(os.listdir(root)): + if own_staging is not None and name == own_staging.name: + continue + if name.startswith("gear-stations") and name != "gear-stations.yml": + raise ValueError("station sibling needs staff review") + if name.startswith("magic-") and name.endswith(".tmp") or name.startswith(LEGACY_EMPTY_STAGING): + raise ValueError("staging evidence needs staff review") + for name in LEGACY_EMPTY_HISTORY: + try: + os.lstat(root / name) + except FileNotFoundError: + continue + raise ValueError("ownership/return history exists (NOFOLLOW): " + name) + station = root / "gear-stations.yml" + try: + info, contents = _legacy_empty_read(station, "station") + except FileNotFoundError as error: + raise ValueError("station file is absent; legacy-empty import does not apply") from error + if info.st_nlink != 1: + raise ValueError("station file has other hard links") + record = { + "data": str(root), "producer": LEGACY_EMPTY_PRODUCERS[producer], "producer-sha256": producer, + "station": {"dev": info.st_dev, "ino": info.st_ino, "size": info.st_size, "sha256": digest(contents), + "mode": oct(stat.S_IMODE(info.st_mode)), "uid": info.st_uid, "gid": info.st_gid, + "mtime-ns": info.st_mtime_ns, "ctime-ns": info.st_ctime_ns, "nlink": info.st_nlink}, + "history-absent": list(LEGACY_EMPTY_HISTORY), "target-sha256": digest(LEGACY_EMPTY_CANONICAL), + } + if contents == LEGACY_EMPTY_CANONICAL and info.st_size == len(LEGACY_EMPTY_CANONICAL): + return record, None, "ALREADY_CANONICAL" + if contents != b"" or info.st_size != 0: + raise ValueError("station file is not exactly zero bytes; ordinary strict recovery applies") + return record, digest(json.dumps(record, sort_keys=True).encode()), "ELIGIBLE_REQUIRES_LINEAGE_ATTESTATION" + + +def legacy_empty_import(root, legacy_jar, apply=False, expect_baseline=None, attest=None, backup=None): + """Explicit stopped import; file state cannot establish the required historical lineage.""" + report, staged, staged_inode, replace_started = {}, None, None, False + try: + root = Path(root) + record, baseline, verdict = _legacy_empty_state(root, legacy_jar) + report.update(verdict=verdict, baseline=baseline, record=record) + if not apply or verdict == "ALREADY_CANONICAL": + return report + if attest is None or backup is None or not isinstance(expect_baseline, str) or not re.fullmatch(r"[0-9a-f]{64}", expect_baseline): + raise ValueError("apply requires --attest FILE, --expect-baseline HEX and a new --backup") + if baseline != expect_baseline: + raise ValueError("current state differs from the attested baseline") + _, attestation = _legacy_empty_read(Path(attest), "attestation") + if not attestation.strip() or not re.search(rb"(?