test_snapshot_content_migrate_cascade.py
python
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
10 days ago
| 1 | """TDD -- muse#83 Phase 2: cascading commit rewrite + re-sign. |
| 2 | |
| 3 | migrate_commit_ids_cascade() must, given a snapshot_id_map from Phase 1: |
| 4 | - rewrite the commit_id of any commit whose OWN snapshot_id is a key in |
| 5 | the map, AND every descendant (transitively, since a descendant's |
| 6 | commit_id embeds its parent's commit_id), |
| 7 | - leave every commit outside that blast radius completely untouched, |
| 8 | - re-sign every rewritten commit with the current signing identity (a |
| 9 | stale signature over a superseded commit_id is a silent authenticity |
| 10 | regression -- this is the one real gap in reusing |
| 11 | muse.core.migrate.migrate_commit_ids as-is, which never re-signs). |
| 12 | """ |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import datetime |
| 16 | import json |
| 17 | import pathlib |
| 18 | |
| 19 | import pytest |
| 20 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 21 | |
| 22 | from muse.core.ids import hash_commit, hash_snapshot |
| 23 | from muse.core.object_store import object_path |
| 24 | from muse.core.paths import objects_dir |
| 25 | from muse.core.provenance import ( |
| 26 | encode_public_key, |
| 27 | provenance_payload, |
| 28 | sign_commit_ed25519, |
| 29 | verify_commit_ed25519, |
| 30 | ) |
| 31 | from muse.core.snapshot_content_migrate import migrate_commit_ids_cascade |
| 32 | from muse.core.transport import SigningIdentity |
| 33 | from muse.core.types import decode_pubkey |
| 34 | |
| 35 | _AT_ISO = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).isoformat() |
| 36 | |
| 37 | |
| 38 | # --------------------------------------------------------------------------- |
| 39 | # Fixtures / helpers |
| 40 | # --------------------------------------------------------------------------- |
| 41 | |
| 42 | |
| 43 | @pytest.fixture |
| 44 | def repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 45 | objects_dir(tmp_path).mkdir(parents=True, exist_ok=True) |
| 46 | return tmp_path |
| 47 | |
| 48 | |
| 49 | def _make_identity(handle: str = "gabriel") -> SigningIdentity: |
| 50 | return SigningIdentity(handle=handle, private_key=Ed25519PrivateKey.generate()) |
| 51 | |
| 52 | |
| 53 | def _sign(commit_id: str, private_key: Ed25519PrivateKey, author: str = "gabriel") -> str: |
| 54 | payload = provenance_payload(commit_id=commit_id, author=author, committed_at=_AT_ISO) |
| 55 | return sign_commit_ed25519(payload, private_key) |
| 56 | |
| 57 | |
| 58 | def _write_unified_commit( |
| 59 | repo: pathlib.Path, |
| 60 | commit_id: str, |
| 61 | *, |
| 62 | parent_commit_id: str | None, |
| 63 | snapshot_id: str, |
| 64 | message: str, |
| 65 | signer_public_key: str = "", |
| 66 | signature: str = "", |
| 67 | ) -> None: |
| 68 | raw = { |
| 69 | "commit_id": commit_id, |
| 70 | "branch": "main", |
| 71 | "snapshot_id": snapshot_id, |
| 72 | "message": message, |
| 73 | "committed_at": _AT_ISO, |
| 74 | "parent_commit_id": parent_commit_id, |
| 75 | "parent2_commit_id": None, |
| 76 | "author": "gabriel", |
| 77 | "agent_id": "", |
| 78 | "model_id": "", |
| 79 | "toolchain_id": "", |
| 80 | "prompt_hash": "", |
| 81 | "signature": signature, |
| 82 | "signer_public_key": signer_public_key, |
| 83 | "signer_key_id": "", |
| 84 | "format_version": 8, |
| 85 | "sem_ver_bump": "none", |
| 86 | "breaking_changes": [], |
| 87 | } |
| 88 | payload = json.dumps(raw, separators=(",", ":")).encode() |
| 89 | header = f"commit {len(payload)}\0".encode() |
| 90 | path = object_path(repo, commit_id) |
| 91 | path.parent.mkdir(parents=True, exist_ok=True) |
| 92 | path.write_bytes(header + payload) |
| 93 | |
| 94 | |
| 95 | def _read_unified_commit(repo: pathlib.Path, commit_id: str) -> dict: |
| 96 | path = object_path(repo, commit_id) |
| 97 | data = path.read_bytes() |
| 98 | null_idx = data.index(b"\x00") |
| 99 | return json.loads(data[null_idx + 1:]) |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # Build the A -> B(bad snapshot) -> C -> D fixture chain from the plan doc |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | |
| 107 | def _build_chain(repo: pathlib.Path, identity: SigningIdentity) -> dict[str, str]: |
| 108 | manifest_a = {"a.txt": "sha256:" + "1" * 64} |
| 109 | snap_a = hash_snapshot(manifest_a, None) |
| 110 | |
| 111 | manifest_b = {"a.txt": "sha256:" + "1" * 64, "b.txt": "sha256:" + "2" * 64} |
| 112 | wrong_snap_b = "sha256:" + "0" * 64 # deliberately wrong, like the real bug |
| 113 | |
| 114 | manifest_c = {**manifest_b, "c.txt": "sha256:" + "3" * 64} |
| 115 | snap_c = hash_snapshot(manifest_c, None) |
| 116 | |
| 117 | manifest_d = {**manifest_c, "d.txt": "sha256:" + "4" * 64} |
| 118 | snap_d = hash_snapshot(manifest_d, None) |
| 119 | |
| 120 | id_a = hash_commit(parent_ids=[], snapshot_id=snap_a, message="A", |
| 121 | committed_at_iso=_AT_ISO, author="gabriel", signer_public_key="") |
| 122 | id_b = hash_commit(parent_ids=[id_a], snapshot_id=wrong_snap_b, message="B", |
| 123 | committed_at_iso=_AT_ISO, author="gabriel", signer_public_key="") |
| 124 | id_c = hash_commit(parent_ids=[id_b], snapshot_id=snap_c, message="C", |
| 125 | committed_at_iso=_AT_ISO, author="gabriel", signer_public_key="") |
| 126 | id_d = hash_commit(parent_ids=[id_c], snapshot_id=snap_d, message="D", |
| 127 | committed_at_iso=_AT_ISO, author="gabriel", signer_public_key="") |
| 128 | |
| 129 | _write_unified_commit(repo, id_a, parent_commit_id=None, snapshot_id=snap_a, message="A", |
| 130 | signature=_sign(id_a, identity.private_key)) |
| 131 | _write_unified_commit(repo, id_b, parent_commit_id=id_a, snapshot_id=wrong_snap_b, message="B", |
| 132 | signature=_sign(id_b, identity.private_key)) |
| 133 | _write_unified_commit(repo, id_c, parent_commit_id=id_b, snapshot_id=snap_c, message="C", |
| 134 | signature=_sign(id_c, identity.private_key)) |
| 135 | _write_unified_commit(repo, id_d, parent_commit_id=id_c, snapshot_id=snap_d, message="D", |
| 136 | signature=_sign(id_d, identity.private_key)) |
| 137 | |
| 138 | return { |
| 139 | "id_a": id_a, "id_b": id_b, "id_c": id_c, "id_d": id_d, |
| 140 | "wrong_snap_b": wrong_snap_b, "correct_snap_b": hash_snapshot(manifest_b, None), |
| 141 | } |
| 142 | |
| 143 | |
| 144 | # --------------------------------------------------------------------------- |
| 145 | # CC_01 -- the cascade rewrites B, C, D and leaves A untouched |
| 146 | # --------------------------------------------------------------------------- |
| 147 | |
| 148 | |
| 149 | def test_cc_01_cascade_rewrites_descendants_and_spares_ancestor(repo: pathlib.Path) -> None: |
| 150 | identity = _make_identity() |
| 151 | ids = _build_chain(repo, identity) |
| 152 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 153 | |
| 154 | a_bytes_before = object_path(repo, ids["id_a"]).read_bytes() |
| 155 | |
| 156 | result = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 157 | |
| 158 | assert ids["id_a"] not in result.id_map, "A has no bad snapshot and no rewritten parent -- must be untouched" |
| 159 | assert ids["id_b"] in result.id_map |
| 160 | assert ids["id_c"] in result.id_map |
| 161 | assert ids["id_d"] in result.id_map |
| 162 | assert len({result.id_map[ids["id_b"]], result.id_map[ids["id_c"]], result.id_map[ids["id_d"]]}) == 3 |
| 163 | |
| 164 | # A is byte-identical -- provably outside the blast radius. |
| 165 | assert object_path(repo, ids["id_a"]).read_bytes() == a_bytes_before |
| 166 | |
| 167 | |
| 168 | # --------------------------------------------------------------------------- |
| 169 | # CC_02 -- the new B correctly carries the corrected snapshot_id, and C/D's |
| 170 | # new records point at B/C's NEW commit_id (the cascade actually threads |
| 171 | # through, not just independently rewritten in isolation) |
| 172 | # --------------------------------------------------------------------------- |
| 173 | |
| 174 | |
| 175 | def test_cc_02_cascade_threads_new_parent_ids_through(repo: pathlib.Path) -> None: |
| 176 | identity = _make_identity() |
| 177 | ids = _build_chain(repo, identity) |
| 178 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 179 | |
| 180 | result = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 181 | |
| 182 | new_b = result.id_map[ids["id_b"]] |
| 183 | new_c = result.id_map[ids["id_c"]] |
| 184 | new_d = result.id_map[ids["id_d"]] |
| 185 | |
| 186 | b_record = _read_unified_commit(repo, new_b) |
| 187 | assert b_record["snapshot_id"] == ids["correct_snap_b"] |
| 188 | assert b_record["parent_commit_id"] == ids["id_a"], "A was not rewritten, so B's parent pointer is unchanged" |
| 189 | |
| 190 | c_record = _read_unified_commit(repo, new_c) |
| 191 | assert c_record["parent_commit_id"] == new_b, "C must point at B's NEW commit_id, not the old one" |
| 192 | |
| 193 | d_record = _read_unified_commit(repo, new_d) |
| 194 | assert d_record["parent_commit_id"] == new_c, "D must point at C's NEW commit_id, not the old one" |
| 195 | |
| 196 | |
| 197 | # --------------------------------------------------------------------------- |
| 198 | # CC_03 -- every rewritten commit is re-signed and the new signature verifies |
| 199 | # against the NEW commit_id (not the old one) |
| 200 | # --------------------------------------------------------------------------- |
| 201 | |
| 202 | |
| 203 | def test_cc_03_rewritten_commits_are_resigned_and_verify(repo: pathlib.Path) -> None: |
| 204 | identity = _make_identity() |
| 205 | ids = _build_chain(repo, identity) |
| 206 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 207 | |
| 208 | result = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 209 | |
| 210 | _, pub_str = encode_public_key(identity.private_key) |
| 211 | _, pub_bytes = decode_pubkey(pub_str) |
| 212 | |
| 213 | for old_id in (ids["id_b"], ids["id_c"], ids["id_d"]): |
| 214 | new_id = result.id_map[old_id] |
| 215 | record = _read_unified_commit(repo, new_id) |
| 216 | assert record["signer_public_key"] == pub_str |
| 217 | |
| 218 | payload = provenance_payload( |
| 219 | commit_id=new_id, |
| 220 | author=record["author"], |
| 221 | agent_id=record.get("agent_id", ""), |
| 222 | model_id=record.get("model_id", ""), |
| 223 | toolchain_id=record.get("toolchain_id", ""), |
| 224 | prompt_hash=record.get("prompt_hash", ""), |
| 225 | committed_at=record["committed_at"], |
| 226 | ) |
| 227 | assert verify_commit_ed25519(payload, record["signature"], pub_bytes), ( |
| 228 | f"re-signed commit {new_id[:18]} does not verify against its own new commit_id" |
| 229 | ) |
| 230 | |
| 231 | |
| 232 | # --------------------------------------------------------------------------- |
| 233 | # CC_04 -- a sibling branch untouched by the corruption is provably unaffected |
| 234 | # --------------------------------------------------------------------------- |
| 235 | |
| 236 | |
| 237 | def test_cc_04_unrelated_sibling_commit_is_byte_identical(repo: pathlib.Path) -> None: |
| 238 | identity = _make_identity() |
| 239 | ids = _build_chain(repo, identity) |
| 240 | |
| 241 | # An unrelated commit forking from A, nothing to do with B/C/D at all. |
| 242 | manifest_e = {"a.txt": "sha256:" + "1" * 64, "e.txt": "sha256:" + "9" * 64} |
| 243 | snap_e = hash_snapshot(manifest_e, None) |
| 244 | id_e = hash_commit(parent_ids=[ids["id_a"]], snapshot_id=snap_e, message="E", |
| 245 | committed_at_iso=_AT_ISO, author="gabriel", signer_public_key="") |
| 246 | _write_unified_commit(repo, id_e, parent_commit_id=ids["id_a"], snapshot_id=snap_e, message="E", |
| 247 | signature=_sign(id_e, identity.private_key)) |
| 248 | e_bytes_before = object_path(repo, id_e).read_bytes() |
| 249 | |
| 250 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 251 | result = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 252 | |
| 253 | assert id_e not in result.id_map |
| 254 | assert object_path(repo, id_e).read_bytes() == e_bytes_before |
| 255 | |
| 256 | |
| 257 | # --------------------------------------------------------------------------- |
| 258 | # CC_05 -- dry_run computes the id_map but writes nothing |
| 259 | # --------------------------------------------------------------------------- |
| 260 | |
| 261 | |
| 262 | def test_cc_05_dry_run_writes_nothing(repo: pathlib.Path) -> None: |
| 263 | identity = _make_identity() |
| 264 | ids = _build_chain(repo, identity) |
| 265 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 266 | |
| 267 | result = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=True) |
| 268 | |
| 269 | assert set(result.id_map) == {ids["id_b"], ids["id_c"], ids["id_d"]} |
| 270 | for old_id in (ids["id_b"], ids["id_c"], ids["id_d"]): |
| 271 | assert not object_path(repo, result.id_map[old_id]).exists() |
| 272 | |
| 273 | |
| 274 | # --------------------------------------------------------------------------- |
| 275 | # CC_06 -- running the cascade twice with the same snapshot_id_map is |
| 276 | # deterministic (same id_map both times). Old (superseded) objects are |
| 277 | # never deleted per the non-destructive invariant, so a second run over the |
| 278 | # same raw objects necessarily rediscovers the same mapping -- this is |
| 279 | # stability, not "second run finds nothing" (that would require scoping the |
| 280 | # scan to ref-reachable objects only, which is a Phase 3/4 concern, not |
| 281 | # this function's). |
| 282 | # --------------------------------------------------------------------------- |
| 283 | |
| 284 | |
| 285 | def test_cc_06_running_twice_is_deterministic(repo: pathlib.Path) -> None: |
| 286 | identity = _make_identity() |
| 287 | ids = _build_chain(repo, identity) |
| 288 | snapshot_id_map = {ids["wrong_snap_b"]: ids["correct_snap_b"]} |
| 289 | |
| 290 | result1 = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 291 | result2 = migrate_commit_ids_cascade(repo, snapshot_id_map, identity, dry_run=False) |
| 292 | |
| 293 | assert result1.id_map == result2.id_map |
| 294 | assert result2.commits_written == 0, "second run must skip -- the new objects already exist" |
| 295 | assert result2.commits_skipped == len(result1.id_map) |
File History
1 commit
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
10 days ago