Harden DHF catalog provenance and review checks

Why: Catalog identity and publication metadata need mechanical checks without implying full asset approval.

Evidence: 16 focused tests and independent scoped review passed; authoritative RDM and human review remain pending.

Impact: Reject symlinked source/package paths, identity drift, invalid reviewer metadata, and incomplete converter provenance.

Refs: feature/rdm-two-trees-latest-templates W0
This commit is contained in:
2026-10-14 04:37:05 +00:00
parent 3262459886
commit 38ef548637
2 changed files with 207 additions and 58 deletions
+80 -10
View File
@@ -7,6 +7,7 @@ meaning or Word rendering fidelity; those require the per-asset review.
import argparse import argparse
from collections import Counter from collections import Counter
from datetime import datetime
import hashlib import hashlib
import json import json
from pathlib import Path from pathlib import Path
@@ -19,6 +20,7 @@ SOURCE_PREFIX = (".templates", "DHF 文件模板")
STATES = {"needs-review", "blocked", "published", "retired"} STATES = {"needs-review", "blocked", "published", "retired"}
SHA256 = re.compile(r"[0-9a-f]{64}\Z") SHA256 = re.compile(r"[0-9a-f]{64}\Z")
COMMIT = re.compile(r"[0-9a-f]{40}\Z") COMMIT = re.compile(r"[0-9a-f]{40}\Z")
RFC3339 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\Z")
def digest(raw): def digest(raw):
@@ -30,6 +32,11 @@ def source_path(dev_root, source):
if (path.is_absolute() or path.parts[:2] != SOURCE_PREFIX if (path.is_absolute() or path.parts[:2] != SOURCE_PREFIX
or ".." in path.parts or path.suffix.lower() not in {".doc", ".docx"}): or ".." in path.parts or path.suffix.lower() not in {".doc", ".docx"}):
raise ValueError(f"unsafe or non-Word source path: {source}") raise ValueError(f"unsafe or non-Word source path: {source}")
segment = dev_root
for part in path.parts:
segment = segment / part
if segment.is_symlink():
raise ValueError(f"source path symlink: {source}")
absolute = dev_root / path absolute = dev_root / path
if not absolute.resolve().is_relative_to(dev_root.resolve()) or absolute.is_symlink(): if not absolute.resolve().is_relative_to(dev_root.resolve()) or absolute.is_symlink():
raise ValueError(f"source path leaves the Dev tree: {source}") raise ValueError(f"source path leaves the Dev tree: {source}")
@@ -44,6 +51,11 @@ def package_digest(root, file_ref):
path = Path(relative) path = Path(relative)
if path.is_absolute() or ".." in path.parts or path == Path("."): if path.is_absolute() or ".." in path.parts or path == Path("."):
raise ValueError("published asset file reference is unsafe") raise ValueError("published asset file reference is unsafe")
segment = root
for part in path.parts:
segment = segment / part
if segment.is_symlink():
raise ValueError("published asset file symlink")
absolute = root / path absolute = root / path
if not absolute.resolve().is_relative_to(root.resolve()) or absolute.is_symlink() or not absolute.is_file(): if not absolute.resolve().is_relative_to(root.resolve()) or absolute.is_symlink() or not absolute.is_file():
raise ValueError("published asset file missing or escapes catalog") raise ValueError("published asset file missing or escapes catalog")
@@ -52,7 +64,8 @@ def package_digest(root, file_ref):
return expected return expected
def audit(dev_root, inventory, catalog, expected_count, require_published=False, catalog_root=None): def audit(dev_root, inventory, catalog, expected_count, require_published=False,
catalog_root=None, identity_baseline=None):
records = inventory.get("templates") records = inventory.get("templates")
assets = catalog.get("assets") assets = catalog.get("assets")
if not isinstance(records, list) or not isinstance(assets, list): if not isinstance(records, list) or not isinstance(assets, list):
@@ -71,6 +84,21 @@ def audit(dev_root, inventory, catalog, expected_count, require_published=False,
raise ValueError("duplicate or missing logical template ID") raise ValueError("duplicate or missing logical template ID")
by_path[path] = asset by_path[path] = asset
ids.add(template_id) ids.add(template_id)
if identity_baseline is not None:
prior = identity_baseline.get("assets")
if not isinstance(prior, list) or len(prior) != expected_count:
raise ValueError("identity baseline is incomplete")
prior_ids = {}
for asset in prior:
if not isinstance(asset, dict):
raise ValueError("identity baseline has invalid asset")
path = (asset.get("sourceProvenance") or {}).get("path")
if path in prior_ids or not isinstance(path, str):
raise ValueError("identity baseline has duplicate source")
prior_ids[path] = asset.get("templateId")
for path, asset in by_path.items():
if prior_ids.get(path) != asset["templateId"]:
raise ValueError(f"template identity drift: {path}")
seen = set() seen = set()
states = Counter() states = Counter()
hashes = set() hashes = set()
@@ -116,23 +144,51 @@ def audit(dev_root, inventory, catalog, expected_count, require_published=False,
raise ValueError(f"invalid asset state: {relative}") raise ValueError(f"invalid asset state: {relative}")
if state == "published": if state == "published":
review = asset.get("review") or {} review = asset.get("review") or {}
if (not catalog_types or not applicability.get("confirmed") or review.get("decision") != "approved" normalized = asset.get("normalizedDocx") or {}
condition = applicability.get("condition")
if (not normalized or not catalog_types or applicability.get("confirmed") is not True
or not isinstance(condition, str) or not condition.strip()
or review.get("decision") != "approved"
or review.get("sourceSha256") != expected or review.get("sourceSha256") != expected
or not asset.get("structure") or not asset.get("render")): or not asset.get("structure") or not asset.get("render")):
raise ValueError(f"published asset lacks approved binding: {relative}") raise ValueError(f"published asset lacks approved binding: {relative}")
if (normalized.get("format") != "docx" or asset["structure"].get("format") != "json"
or asset["render"].get("format") != "docx"):
raise ValueError(f"published asset has invalid derived formats: {relative}")
reviewer = review.get("reviewer")
evidence = review.get("evidence")
reviewed_at = review.get("reviewedAt")
if (not isinstance(reviewer, str) or not reviewer.strip()
or not isinstance(evidence, str) or not evidence.strip()
or not isinstance(reviewed_at, str)):
raise ValueError(f"published asset lacks reviewer evidence: {relative}")
if not RFC3339.fullmatch(reviewed_at):
raise ValueError(f"published asset has invalid review time: {relative}")
try:
at = datetime.fromisoformat(reviewed_at.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"published asset has invalid review time: {relative}") from exc
if at.tzinfo is None:
raise ValueError(f"published asset has invalid review time: {relative}")
converter = asset.get("converter") or {}
if (asset["source"].get("format") == "doc" or
asset["source"].get("sha256") != normalized.get("sha256")):
if (converter.get("inputSha256") != expected or
converter.get("outputSha256") != normalized.get("sha256") or
any(not isinstance(converter.get(field), str) or not converter[field].strip()
for field in ("name", "version", "configuration"))):
raise ValueError(f"published asset lacks conversion provenance: {relative}")
if any(issue.get("status") != "resolved" for issue in asset.get("issues", [])): if any(issue.get("status") != "resolved" for issue in asset.get("issues", [])):
raise ValueError(f"published asset has unresolved issues: {relative}") raise ValueError(f"published asset has unresolved issues: {relative}")
if (review.get("structureSha256") != asset["structure"].get("sha256") if (review.get("structureSha256") != asset["structure"].get("sha256")
or review.get("renderSha256") != asset["render"].get("sha256") or review.get("renderSha256") != asset["render"].get("sha256")
or (asset.get("normalizedDocx") and or review.get("normalizedSha256") != normalized.get("sha256")):
review.get("normalizedSha256") != asset["normalizedDocx"].get("sha256"))):
raise ValueError(f"published asset review hash mismatch: {relative}") raise ValueError(f"published asset review hash mismatch: {relative}")
if catalog_root is not None: if catalog_root is not None:
package_digest(catalog_root, asset["source"]) package_digest(catalog_root, asset["source"])
package_digest(catalog_root, normalized)
package_digest(catalog_root, asset["structure"]) package_digest(catalog_root, asset["structure"])
package_digest(catalog_root, asset["render"]) package_digest(catalog_root, asset["render"])
if asset.get("normalizedDocx"):
package_digest(catalog_root, asset["normalizedDocx"])
states[state] += 1 states[state] += 1
hashes.add(expected) hashes.add(expected)
if require_published and states.get("published", 0) != expected_count: if require_published and states.get("published", 0) != expected_count:
@@ -145,7 +201,9 @@ def audit(dev_root, inventory, catalog, expected_count, require_published=False,
"published": states.get("published", 0), "published": states.get("published", 0),
"states": dict(sorted(states.items())), "states": dict(sorted(states.items())),
"candidateTypeDifferences": candidate_differences, "candidateTypeDifferences": candidate_differences,
"limits": "This does not verify content classification, applicability decisions, Word fidelity or real product behavior.", "identityBaselineVerified": identity_baseline is not None,
"publicationValidation": "NOT_RUN: RDM LoadDHFAssetCatalog and human content/Word review are authoritative",
"limits": "This checks provenance and obvious metadata errors; state counts do not prove published bundles are valid.",
} }
@@ -155,14 +213,26 @@ def main():
parser.add_argument("--inventory", type=Path, required=True) parser.add_argument("--inventory", type=Path, required=True)
parser.add_argument("--catalog", type=Path, required=True) parser.add_argument("--catalog", type=Path, required=True)
parser.add_argument("--expected-count", type=int, default=44) parser.add_argument("--expected-count", type=int, default=44)
parser.add_argument("--identity-baseline", type=Path)
parser.add_argument("--require-published", action="store_true") parser.add_argument("--require-published", action="store_true")
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.expected_count < 1: if args.expected_count < 1:
raise ValueError("expected-count must be positive") raise ValueError("expected-count must be positive")
result = audit(args.dev_root, json.loads(args.inventory.read_text()), inventory_raw = args.inventory.read_bytes()
json.loads(args.catalog.read_text()), args.expected_count, catalog_raw = args.catalog.read_bytes()
args.require_published, args.catalog.parent) baseline_raw = args.identity_baseline.read_bytes() if args.identity_baseline else None
result = audit(args.dev_root, json.loads(inventory_raw), json.loads(catalog_raw),
args.expected_count, args.require_published, args.catalog.parent,
json.loads(baseline_raw) if baseline_raw is not None else None)
if args.catalog.read_bytes() != catalog_raw:
raise ValueError("catalog changed during check")
result["catalogSha256"] = digest(catalog_raw)
result["inventorySha256"] = digest(inventory_raw)
if baseline_raw is not None:
result["identityBaselineSha256"] = digest(baseline_raw)
if args.require_published:
raise ValueError("RDM publication validator required for full bundle safety and human fidelity")
except (OSError, ValueError, json.JSONDecodeError) as exc: except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"dhf catalog guard: {exc}", file=sys.stderr) print(f"dhf catalog guard: {exc}", file=sys.stderr)
return 1 return 1
+127 -48
View File
@@ -5,6 +5,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from copy import deepcopy
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -65,6 +66,32 @@ class DHFCatalogGuardTest(unittest.TestCase):
"--expected-count", "2", *extra, "--expected-count", "2", *extra,
], capture_output=True, text=True) ], capture_output=True, text=True)
def publish_fixture(self, index=0, *, structure=b'{"blocks":[]}', render=b"rendered-docx"):
asset = self.assets[index]
number = index + 1
source = (self.dev / self.items[index]["source"]).read_bytes()
names = {"source": f"source{number}.docx", "structure": f"structure{number}.json",
"render": f"render{number}.docx"}
for label, raw in [("source", source), ("structure", structure), ("render", render)]:
(self.root / names[label]).write_bytes(raw)
asset["state"] = "published"
asset["applicability"].update({"confirmed": True, "condition": "test scope"})
asset["source"]["path"] = names["source"]
asset["normalizedDocx"] = {"path": names["source"], "format": "docx",
"sha256": hashlib.sha256(source).hexdigest()}
asset["structure"] = {"path": names["structure"], "format": "json",
"sha256": hashlib.sha256(structure).hexdigest()}
asset["render"] = {"path": names["render"], "format": "docx",
"sha256": hashlib.sha256(render).hexdigest()}
asset["review"] = {
"decision": "approved", "reviewer": "reviewer", "reviewedAt": "2026-10-14T00:00:00Z",
"evidence": "review-record", "sourceSha256": self.items[index]["sourceSha256"],
"normalizedSha256": asset["normalizedDocx"]["sha256"],
"structureSha256": asset["structure"]["sha256"],
"renderSha256": asset["render"]["sha256"],
}
return asset
def test_accepts_matching_immutable_sources_but_does_not_call_them_published(self): def test_accepts_matching_immutable_sources_but_does_not_call_them_published(self):
result = self.run_guard() result = self.run_guard()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@@ -79,6 +106,16 @@ class DHFCatalogGuardTest(unittest.TestCase):
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("checkout hash mismatch", result.stderr) self.assertIn("checkout hash mismatch", result.stderr)
def test_rejects_original_in_symlinked_parent_directory(self):
source = self.dev / self.items[0]["source"]
original_parent = source.parent
moved_parent = original_parent.parent / "alias-target"
original_parent.rename(moved_parent)
original_parent.symlink_to(moved_parent, target_is_directory=True)
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("source path symlink", result.stderr)
def test_rejects_false_publication(self): def test_rejects_false_publication(self):
self.assets[0]["state"] = "published" self.assets[0]["state"] = "published"
self.save() self.save()
@@ -106,72 +143,114 @@ class DHFCatalogGuardTest(unittest.TestCase):
self.assertIn("not published", result.stderr) self.assertIn("not published", result.stderr)
def test_rejects_published_asset_whose_review_does_not_bind_bundle_bytes(self): def test_rejects_published_asset_whose_review_does_not_bind_bundle_bytes(self):
structure = b'{"blocks":[]}' asset = self.publish_fixture()
render = b"rendered-docx" asset["review"]["structureSha256"] = "0" * 64
(self.root / "structure.json").write_bytes(structure)
(self.root / "render.docx").write_bytes(render)
asset = self.assets[0]
asset["state"] = "published"
asset["applicability"]["confirmed"] = True
asset["structure"] = {"path": "structure.json", "sha256": hashlib.sha256(structure).hexdigest()}
asset["render"] = {"path": "render.docx", "sha256": hashlib.sha256(render).hexdigest()}
asset["review"] = {
"decision": "approved", "sourceSha256": self.items[0]["sourceSha256"],
"structureSha256": "0" * 64,
"renderSha256": hashlib.sha256(render).hexdigest(),
}
self.save() self.save()
result = self.run_guard() result = self.run_guard()
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset review hash mismatch", result.stderr) self.assertIn("published asset review hash mismatch", result.stderr)
def test_rejects_bundle_file_changed_after_approval(self): def test_rejects_bundle_file_changed_after_approval(self):
structure = b'{"blocks":[]}' asset = self.publish_fixture()
render = b"rendered-docx"
original = (self.dev / self.items[0]["source"]).read_bytes()
(self.root / "source.docx").write_bytes(original)
(self.root / "structure.json").write_bytes(structure)
(self.root / "render.docx").write_bytes(render)
asset = self.assets[0]
asset["state"] = "published"
asset["applicability"]["confirmed"] = True
asset["source"]["path"] = "source.docx"
asset["structure"] = {"path": "structure.json", "sha256": hashlib.sha256(structure).hexdigest()}
asset["render"] = {"path": "render.docx", "sha256": hashlib.sha256(render).hexdigest()}
asset["review"] = {
"decision": "approved", "sourceSha256": self.items[0]["sourceSha256"],
"structureSha256": asset["structure"]["sha256"],
"renderSha256": asset["render"]["sha256"],
}
self.save() self.save()
(self.root / "render.docx").write_bytes(b"modified-after-review") (self.root / asset["render"]["path"]).write_bytes(b"modified-after-review")
result = self.run_guard() result = self.run_guard()
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset file hash mismatch", result.stderr) self.assertIn("published asset file hash mismatch", result.stderr)
def test_rejects_published_asset_with_unresolved_fidelity_issue(self): def test_rejects_published_asset_with_unresolved_fidelity_issue(self):
structure = b'{"blocks":[]}' asset = self.publish_fixture()
render = b"rendered-docx"
(self.root / "source.docx").write_bytes((self.dev / self.items[0]["source"]).read_bytes())
(self.root / "structure.json").write_bytes(structure)
(self.root / "render.docx").write_bytes(render)
asset = self.assets[0]
asset["state"] = "published"
asset["applicability"]["confirmed"] = True
asset["source"]["path"] = "source.docx"
asset["structure"] = {"path": "structure.json", "sha256": hashlib.sha256(structure).hexdigest()}
asset["render"] = {"path": "render.docx", "sha256": hashlib.sha256(render).hexdigest()}
asset["review"] = {
"decision": "approved", "sourceSha256": self.items[0]["sourceSha256"],
"structureSha256": asset["structure"]["sha256"],
"renderSha256": asset["render"]["sha256"],
}
asset["issues"] = [{"id": "fidelity", "status": "open"}] asset["issues"] = [{"id": "fidelity", "status": "open"}]
self.save() self.save()
result = self.run_guard() result = self.run_guard()
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset has unresolved issues", result.stderr) self.assertIn("published asset has unresolved issues", result.stderr)
def test_publication_flag_never_claims_authoritative_rdm_validation(self):
for index in range(2):
self.publish_fixture(index, structure=b"not-a-valid-structure", render=b"not-a-valid-docx")
self.save()
result = self.run_guard("--require-published")
self.assertNotEqual(result.returncode, 0)
self.assertIn("RDM publication validator required", result.stderr)
def test_rejects_swapped_logical_ids_against_fixed_identity_baseline(self):
baseline = self.root / "identity-baseline.json"
baseline.write_text(json.dumps({"assets": deepcopy(self.assets)}))
self.assets[0]["templateId"], self.assets[1]["templateId"] = (
self.assets[1]["templateId"], self.assets[0]["templateId"])
self.save()
result = self.run_guard("--identity-baseline", str(baseline))
self.assertNotEqual(result.returncode, 0)
self.assertIn("template identity drift", result.stderr)
def test_report_binds_exact_catalog_bytes(self):
result = self.run_guard()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["catalogSha256"],
hashlib.sha256(self.catalog.read_bytes()).hexdigest())
def test_rejects_published_package_through_symlink_directory(self):
asset = self.publish_fixture()
real = self.root / "real"
real.mkdir()
for key in ["source", "normalizedDocx", "structure", "render"]:
p = self.root / asset[key]["path"]
(real / p.name).write_bytes(p.read_bytes())
asset[key]["path"] = "alias/" + p.name
(self.root / "alias").symlink_to(real, target_is_directory=True)
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlink", result.stderr)
def test_rejects_string_false_and_null_applicability(self):
asset = self.publish_fixture()
asset["applicability"]["confirmed"] = "false"
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset lacks approved binding", result.stderr)
asset["applicability"]["confirmed"] = True
asset["applicability"]["condition"] = None
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset lacks approved binding", result.stderr)
def test_rejects_null_reviewer_and_non_rfc3339_timestamp(self):
asset = self.publish_fixture()
asset["review"]["reviewer"] = None
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset lacks reviewer evidence", result.stderr)
asset["review"]["reviewer"] = "reviewer"
asset["review"]["reviewedAt"] = "2026-10-14 00:00:00Z"
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset has invalid review time", result.stderr)
def test_rejects_changed_normalized_file_without_named_converter(self):
asset = self.publish_fixture()
normalized = b"changed-normalized-docx"
(self.root / "normalized.docx").write_bytes(normalized)
asset["normalizedDocx"] = {
"path": "normalized.docx", "format": "docx", "sha256": hashlib.sha256(normalized).hexdigest(),
}
asset["review"]["normalizedSha256"] = asset["normalizedDocx"]["sha256"]
asset["converter"] = {
"inputSha256": self.items[0]["sourceSha256"],
"outputSha256": asset["normalizedDocx"]["sha256"],
}
self.save()
result = self.run_guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("published asset lacks conversion provenance", result.stderr)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()