From 65862b1e0c6eaa4c96e1e48131db2ac7e914eb36 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 02:31:41 +0200 Subject: [PATCH] Reject history overwrite and unreviewed publication routing changes Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- tests/test_publication_transition.py | 19 +++++++++++++++++++ tests/test_published_history.py | 23 +++++++++++++++++++++++ tools/build_site.py | 12 ++++++++++-- tools/verify_publication_transition.py | 9 ++++++--- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/tests/test_publication_transition.py b/tests/test_publication_transition.py index 90595e5..d0e0161 100644 --- a/tests/test_publication_transition.py +++ b/tests/test_publication_transition.py @@ -47,3 +47,22 @@ class TransitionTests(unittest.TestCase): self.old.write_text(json.dumps({'documents':[self.doc]})) self.audit.write_text(json.dumps({'immutable_results':[{'id':'a','revision_path':'../outside','http_status':200,'served_html_sha256':'a'*64}]})) with self.assertRaisesRegex(ValueError,'unsafe'): self.verify() + + def test_address_and_owner_routing_changes_require_review(self): + for field in ('canonical_path','revision_path','source_repo','source_path','owner'): + with self.subTest(field=field): + self.write_candidate(self.doc | {field:'changed'}) + with self.assertRaisesRegex(ValueError,'unreviewed'): + self.verify() + + def test_symlink_parent_cannot_supply_history_from_outside_candidate(self): + import shutil + outside = self.root.parent / (self.root.name + '-outside') + outside.mkdir() + self.addCleanup(lambda: shutil.rmtree(outside)) + (outside/'revisions/1').mkdir(parents=True) + (outside/'revisions/1/index.html').write_bytes(b'original') + shutil.rmtree(self.root/'a') + (self.root/'a').symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError,'absent or unsafe'): + self.verify() diff --git a/tests/test_published_history.py b/tests/test_published_history.py index e5196f4..d3b184b 100644 --- a/tests/test_published_history.py +++ b/tests/test_published_history.py @@ -64,3 +64,26 @@ class PublishedHistoryTests(unittest.TestCase): def test_changed_source_under_same_revision_refuses(self): self.source.write_text(self.source.read_text().replace('No position.','Changed position.')) with self.assertRaisesRegex(RuntimeError,'Publish a new revision'):self.clean_build() + + def test_canonical_cannot_overwrite_archived_revision(self): + manifest=json.loads(self.manifest.read_text()) + manifest['documents'][0]['canonical_path']=self.doc['revision_path'] + self.manifest.write_text(json.dumps(manifest)) + # Bump current revision so the ordinary manifest path check cannot catch it. + self.source.write_text(self.source.read_text().replace('draft-1','draft-2')) + with self.assertRaisesRegex(ValueError,'archived immutable'): + self.clean_build() + + def test_legacy_redirect_cannot_overwrite_archived_revision(self): + manifest=json.loads(self.manifest.read_text()) + manifest['documents'][0]['legacy_paths']=[self.doc['revision_path']] + self.manifest.write_text(json.dumps(manifest)) + self.source.write_text(self.source.read_text().replace('draft-1','draft-2')) + with self.assertRaisesRegex(ValueError,'archived immutable'): + self.clean_build() + + def test_delivered_commit_field_must_match_captured_manifest(self): + self.receipt['documents'][0]['delivered_manifest_source_revision']='c'*40 + self.configure() + with self.assertRaisesRegex(RuntimeError,'delivered source revision'): + self.clean_build() diff --git a/tools/build_site.py b/tools/build_site.py index 4795a78..18dec82 100644 --- a/tools/build_site.py +++ b/tools/build_site.py @@ -162,7 +162,7 @@ def _restore_historical_revisions( target.write_bytes(page_bytes) -def _restore_published_history(manifest_path: Path, manifest: dict[str, Any], output: Path) -> None: +def _restore_published_history(manifest_path: Path, manifest: dict[str, Any], output: Path) -> set[PurePosixPath]: """Restore publisher-held observed bytes, never re-render old owner decisions.""" seen: set[PurePosixPath] = set() root = manifest_path.parent.resolve() @@ -198,6 +198,9 @@ def _restore_published_history(manifest_path: Path, manifest: dict[str, Any], ou for field in ("revision", "revision_path", "source_repo", "source_path", "source_digest"): if record[field] != original[field]: raise RuntimeError("published history differs from delivered manifest") + delivered_revision = record.get("delivered_manifest_source_revision", record["source_revision"]) + if delivered_revision != original["source_revision"]: + raise RuntimeError("published history delivered source revision mismatch") revision_path = _safe_path(record["revision_path"]) if "/revisions/" not in revision_path.as_posix() or revision_path in seen: raise ValueError("published history requires unique immutable revision paths") @@ -225,6 +228,7 @@ def _restore_published_history(manifest_path: Path, manifest: dict[str, Any], ou raise RuntimeError("published history immutable path conflicts with existing bytes") target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(page_bytes) + return seen def _redirect(target: str, title: str) -> str: @@ -299,7 +303,11 @@ def build( records: list[dict[str, str]] = [] try: - _restore_published_history(manifest_path, manifest, temporary) + archived_paths = _restore_published_history(manifest_path, manifest, temporary) + for document in manifest["documents"]: + for raw in (document["canonical_path"], *document.get("legacy_paths", [])): + if _safe_path(raw) in archived_paths: + raise ValueError("current publication write conflicts with archived immutable path") for document in manifest["documents"]: repo = resolved_repositories[document["source_repo"]] source = (repo / document["source_path"]).resolve() diff --git a/tools/verify_publication_transition.py b/tools/verify_publication_transition.py index cd06b93..10c1443 100644 --- a/tools/verify_publication_transition.py +++ b/tools/verify_publication_transition.py @@ -5,7 +5,8 @@ import hashlib import json from pathlib import Path, PurePosixPath -SEMANTIC_FIELDS = ('source_digest', 'revision', 'status', 'lifecycle', 'currency') +SEMANTIC_FIELDS = ('source_digest', 'revision', 'status', 'lifecycle', 'currency', + 'canonical_path', 'revision_path', 'source_repo', 'source_path', 'owner') def safe_path(value): p = PurePosixPath(value) @@ -14,7 +15,7 @@ def safe_path(value): return p def verify(candidate, baseline_manifest, immutable_audit, allowed_changed_ids): - candidate = Path(candidate) + candidate = Path(candidate).resolve() old = json.loads(Path(baseline_manifest).read_text())['documents'] new = json.loads((candidate / 'publication-manifest.json').read_text())['documents'] old_by = {d['id']: d for d in old} @@ -43,7 +44,9 @@ def verify(candidate, baseline_manifest, immutable_audit, allowed_changed_ids): errors.append(f'{identity}: captured path differs from baseline manifest') continue path = candidate / safe_path(record['revision_path']) - if not path.is_file() or path.is_symlink(): + if (not path.is_file() or path.is_symlink() + or candidate not in path.resolve().parents + or any(parent.is_symlink() for parent in path.parents if parent != candidate and candidate in parent.parents)) : errors.append(f'{identity}: old immutable revision absent or unsafe') elif hashlib.sha256(path.read_bytes()).hexdigest() != record['served_html_sha256']: errors.append(f'{identity}: old immutable bytes changed')