gabriel / musehub public
test_wire_push_root_snapshot_integrity.py python
213 lines 7.9 KB
Raw
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 1 day ago
1 """TDD — musehub#93: root-of-push snapshots were never hash-verified before storage.
2
3 Root cause, in ``musehub_wire_push.py``'s snapshot-processing loop::
4
5 if _parent_sid and hash_snapshot(_base, _snap_dirs or None) != _sid:
6 raise ValueError(...)
7
8 This integrity check only ran when ``_parent_sid`` was truthy. Every snapshot
9 that is the *root* of a push batch (``parent_snapshot_id=None`` — the first
10 commit sent, or a genuinely new repo's first commit) has ``_parent_sid`` falsy
11 and was therefore never checked against its own declared ID at all. A wire/
12 transport bug that dropped or mangled ``directories`` (or, in principle, the
13 manifest itself) for exactly this kind of entry would sail through unverified
14 and be persisted permanently -- undetectable until a client later re-hashed it
15 on clone and rejected the mismatch (the exact `entries=1062 dirs=0` corruption
16 found live on `gabriel/muse`@staging that prompted this issue).
17
18 RED before the fix: a root snapshot with directories that don't reproduce its
19 own declared snapshot_id is silently stored with dirs=0.
20 GREEN after: the push is rejected outright, matching the existing (and
21 unaffected) behavior for a snapshot with a bad/phantom parent.
22 """
23 from __future__ import annotations
24
25 import datetime
26 from unittest.mock import AsyncMock, MagicMock, patch
27
28 import msgpack
29 import pytest
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from muse.core.ids import hash_snapshot
33 from muse.core.mpack import build_wire_mpack
34 from muse.core.types import blob_id
35 from musehub.core.genesis import compute_identity_id
36 from musehub.db.musehub_repo_models import MusehubSnapshot
37 from musehub.services.musehub_repository import create_repo
38 from musehub.services.musehub_wire_push import wire_push_unpack_mpack
39
40 _OWNER = "gabriel"
41 _IDENTITY_ID = compute_identity_id(b"gabriel")
42
43
44 def _cid(seed: str) -> str:
45 return blob_id(f"root-integrity-commit-{seed}".encode())
46
47
48 def _now() -> datetime.datetime:
49 return datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
50
51
52 def _raw_commit(cid: str, snap_id: str) -> dict:
53 return {
54 "commit_id": cid,
55 "branch": "feat",
56 "message": f"commit {cid[:12]}",
57 "author": _OWNER,
58 "committed_at": _now().isoformat(),
59 "parent_commit_id": None,
60 "parent2_commit_id": None,
61 "snapshot_id": snap_id,
62 "agent_id": "",
63 "model_id": "",
64 "toolchain_id": "",
65 "sem_ver_bump": "none",
66 "breaking_changes": [],
67 "signature": "",
68 "signer_key_id": "",
69 "signer_public_key": "",
70 "prompt_hash": "",
71 }
72
73
74 def _mock_backend(mpack_bytes: bytes) -> MagicMock:
75 backend = MagicMock()
76 backend.get_mpack = AsyncMock(return_value=mpack_bytes)
77 backend.put = AsyncMock(return_value=None)
78 backend.put_mpack = AsyncMock(return_value=None)
79 backend.quarantine_mpack = AsyncMock(return_value=None)
80 backend.presign_get = AsyncMock(return_value="")
81 backend.presign_mpack_get = AsyncMock(return_value="")
82 return backend
83
84
85 @pytest.mark.asyncio
86 async def test_root_snapshot_with_wrong_directories_is_rejected_not_silently_stored(
87 db_session: AsyncSession,
88 ) -> None:
89 """RED: a root (parent_snapshot_id=None) snapshot's declared directories
90 don't reproduce its own snapshot_id. Must be rejected outright -- exactly
91 like the existing phantom-parent case -- not persisted with the wrong
92 (or dropped) directories."""
93 repo = await create_repo(
94 db_session,
95 name="root-integrity-repro",
96 owner=_OWNER,
97 owner_user_id=_IDENTITY_ID,
98 visibility="public",
99 initialize=False,
100 )
101 await db_session.commit()
102
103 oid1 = blob_id(b"content-f1")
104 manifest = {"tracks/f1.txt": oid1}
105 real_directories = ["tracks", "docs"]
106 # The snapshot_id was genuinely computed WITH these directories locally --
107 # this is what a correct client push looks like.
108 snap_id = hash_snapshot(manifest, real_directories)
109
110 a_commit = _cid("a")
111 mpack_bytes = build_wire_mpack({
112 "commits": [_raw_commit(a_commit, snap_id)],
113 "snapshots": [{
114 "snapshot_id": snap_id,
115 "parent_snapshot_id": None,
116 "delta_upsert": manifest,
117 "delta_remove": [],
118 # Wire/transport bug simulation: directories arrived wrong (here:
119 # dropped entirely) relative to what snap_id was actually hashed
120 # with. This is exactly the shape of musehub#93's live corruption.
121 "directories": [],
122 }],
123 "blobs": [{"object_id": oid1, "content": b"content-f1"}],
124 "tags": [],
125 })
126 mpack_key = blob_id(mpack_bytes)
127 backend = _mock_backend(mpack_bytes)
128
129 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
130 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
131 patch("musehub.storage.backends.get_backend", return_value=backend):
132 with pytest.raises(ValueError, match="does not reproduce its own declared ID"):
133 await wire_push_unpack_mpack(
134 db_session,
135 repo.repo_id,
136 mpack_key,
137 pusher_id=_OWNER,
138 branch="feat",
139 head_commit_id=a_commit,
140 commits_count=1,
141 blobs_count=1,
142 force=True,
143 )
144
145 stored = await db_session.get(MusehubSnapshot, snap_id)
146 assert stored is None, (
147 "a root snapshot whose directories don't reproduce its own declared ID "
148 "must never be persisted -- this is exactly the musehub#93 corruption "
149 "(dirs silently dropped, entry_count correct) sailing through unverified"
150 )
151
152
153 @pytest.mark.asyncio
154 async def test_root_snapshot_with_correct_directories_is_stored_intact(
155 db_session: AsyncSession,
156 ) -> None:
157 """GREEN control: a correctly-hashed root snapshot with real directories
158 must still push successfully and be stored with those directories intact --
159 the new unconditional check must not reject legitimate pushes."""
160 repo = await create_repo(
161 db_session,
162 name="root-integrity-happy-path",
163 owner=_OWNER,
164 owner_user_id=_IDENTITY_ID,
165 visibility="public",
166 initialize=False,
167 )
168 await db_session.commit()
169
170 oid1 = blob_id(b"content-f1-happy")
171 manifest = {"tracks/f1.txt": oid1}
172 real_directories = ["tracks", "docs", "empty_dir"]
173 snap_id = hash_snapshot(manifest, real_directories)
174
175 a_commit = _cid("happy-a")
176 mpack_bytes = build_wire_mpack({
177 "commits": [_raw_commit(a_commit, snap_id)],
178 "snapshots": [{
179 "snapshot_id": snap_id,
180 "parent_snapshot_id": None,
181 "delta_upsert": manifest,
182 "delta_remove": [],
183 "directories": real_directories,
184 }],
185 "blobs": [{"object_id": oid1, "content": b"content-f1-happy"}],
186 "tags": [],
187 })
188 mpack_key = blob_id(mpack_bytes)
189 backend = _mock_backend(mpack_bytes)
190
191 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
192 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
193 patch("musehub.storage.backends.get_backend", return_value=backend):
194 await wire_push_unpack_mpack(
195 db_session,
196 repo.repo_id,
197 mpack_key,
198 pusher_id=_OWNER,
199 branch="feat",
200 head_commit_id=a_commit,
201 commits_count=1,
202 blobs_count=1,
203 force=True,
204 )
205 await db_session.flush()
206
207 stored = await db_session.get(MusehubSnapshot, snap_id)
208 assert stored is not None, "a correctly-hashed root snapshot must be stored"
209 assert sorted(stored.directories or []) == sorted(real_directories), (
210 f"stored directories don't match what was pushed: "
211 f"stored={stored.directories!r} pushed={real_directories!r}"
212 )
213 assert hash_snapshot(manifest, list(stored.directories or [])) == snap_id
File History 1 commit
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 1 day ago