gabriel / muse public
test_bare_hex_rejection.py python
414 lines 16.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """TDD: bare hex IDs are rejected at every CLI boundary.
2
3 The sha256: prefix is a type tag, not decoration. It tells the system which
4 algorithm produced the hash. Accepting bare hex at CLI boundaries forecloses
5 future algorithm agility — if we ever add blake3: IDs, bare hex becomes
6 fatally ambiguous.
7
8 Architecture note
9 -----------------
10 Enforcement belongs at the CLI outer shell — the hard boundary where untrusted
11 user input enters the system. Internal functions like resolve_commit_ref()
12 operate on already-validated input; they are not the primary enforcement point.
13 Defense-in-depth at the core is a bonus, not the design.
14
15 Rule (always, without exception)
16 ---------------------------------
17 - sha256:<64 lowercase hex> — full ID, accepted everywhere.
18 - sha256:<short prefix> — prefix resolution, accepted.
19 - <bare hex, any length> — REJECTED at the CLI boundary with a clear error.
20
21 The only place bare hex appears is on disk (filenames) — stripped on write,
22 restored on read. Users never see it; agents never pass it.
23
24 Covered boundaries
25 ------------------
26 - muse snapshot read <id>
27 - muse snapshot export <id>
28 - muse snapshot-diff <ref_a> <ref_b>
29 - muse verify-commit <id>
30 """
31
32 from __future__ import annotations
33
34 import datetime
35 import json
36 import pathlib
37
38 from muse.core._types import Manifest, blob_id, long_id, short_id
39 from muse.core.object_store import write_object
40 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
41 from muse.core.store import (
42 CommitRecord,
43 SnapshotRecord,
44 write_commit,
45 write_snapshot,
46 )
47 from tests.cli_test_helper import CliRunner
48
49 cli = None
50 runner = CliRunner()
51
52
53 # ---------------------------------------------------------------------------
54 # Helpers
55 # ---------------------------------------------------------------------------
56
57 _BARE_HEX_FULL = "a" * 64 # 64 hex chars, no prefix
58 _BARE_HEX_SHORT = "abc123def456" # short hex prefix, no prefix
59 _INVALID_LOOK = "deadbeef" # 8 hex chars, no prefix
60
61
62 def _init_repo(path: pathlib.Path) -> pathlib.Path:
63 muse = path / ".muse"
64 for d in ("commits", "snapshots", "objects", "refs/heads"):
65 (muse / d).mkdir(parents=True, exist_ok=True)
66 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
67 (muse / "repo.json").write_text(
68 json.dumps({"repo_id": "bare-hex-test", "domain": "code"}), encoding="utf-8"
69 )
70 return path
71
72
73 def _env(repo: pathlib.Path) -> Manifest:
74 return {"MUSE_REPO_ROOT": str(repo)}
75
76
77 def _obj(repo: pathlib.Path, content: bytes) -> str:
78 oid = blob_id(content)
79 write_object(repo, oid, content)
80 return oid
81
82
83 def _snap(repo: pathlib.Path, manifest: Manifest) -> str:
84 sid = compute_snapshot_id(manifest)
85 write_snapshot(
86 repo,
87 SnapshotRecord(
88 snapshot_id=sid,
89 manifest=manifest,
90 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
91 ),
92 )
93 return sid
94
95
96 def _commit(repo: pathlib.Path, sid: str, branch: str = "main") -> str:
97 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
98 cid = compute_commit_id(
99 repo_id="bare-hex-test",
100 parent_ids=[],
101 snapshot_id=sid,
102 message="test",
103 committed_at_iso=committed_at.isoformat(),
104 author="tester",
105 )
106 write_commit(
107 repo,
108 CommitRecord(
109 commit_id=cid,
110 repo_id="bare-hex-test",
111 created_on_branch=branch,
112 snapshot_id=sid,
113 message="test",
114 committed_at=committed_at,
115 author="tester",
116 parent_commit_id=None,
117 ),
118 )
119 ref = repo / ".muse" / "refs" / "heads" / branch
120 ref.write_text(cid, encoding="utf-8")
121 return cid
122
123
124 def _create_snapshot_and_commit(repo: pathlib.Path) -> tuple[str, str]:
125 """Return (snapshot_id, commit_id) for a one-file repo snapshot."""
126 oid = _obj(repo, b"hello world")
127 sid = _snap(repo, {"file.txt": oid})
128 cid = _commit(repo, sid)
129 return sid, cid
130
131
132 # ---------------------------------------------------------------------------
133 # muse snapshot read — bare hex must be rejected
134 # ---------------------------------------------------------------------------
135
136
137 class TestSnapshotReadBareHexRejected:
138 """snapshot read must reject bare hex, full or short."""
139
140 def test_full_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
141 repo = _init_repo(tmp_path)
142 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_FULL], env=_env(repo))
143 assert result.exit_code != 0, "bare full 64-char hex must be rejected"
144
145 def test_short_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
146 repo = _init_repo(tmp_path)
147 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_SHORT], env=_env(repo))
148 assert result.exit_code != 0, "bare short hex must be rejected"
149
150 def test_8_char_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
151 repo = _init_repo(tmp_path)
152 result = runner.invoke(cli, ["snapshot", "read", _INVALID_LOOK], env=_env(repo))
153 assert result.exit_code != 0, "any bare hex must be rejected"
154
155 def test_error_message_mentions_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
156 repo = _init_repo(tmp_path)
157 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_SHORT], env=_env(repo))
158 assert result.exit_code != 0
159 assert "sha256:" in result.output.lower() or "sha256:" in (result.stderr or "").lower(), (
160 "error message must tell the user to use sha256: prefix"
161 )
162
163 def test_prefixed_full_id_accepted(self, tmp_path: pathlib.Path) -> None:
164 repo = _init_repo(tmp_path)
165 sid, _ = _create_snapshot_and_commit(repo)
166 result = runner.invoke(cli, ["snapshot", "read", sid], env=_env(repo))
167 assert result.exit_code == 0, f"sha256: prefixed full ID must be accepted; got: {result.output}"
168
169 def test_prefixed_short_id_accepted(self, tmp_path: pathlib.Path) -> None:
170 repo = _init_repo(tmp_path)
171 sid, _ = _create_snapshot_and_commit(repo)
172 # Short prefix: sha256: + first 12 hex chars
173 short_prefixed = short_id(sid)
174 result = runner.invoke(cli, ["snapshot", "read", short_prefixed], env=_env(repo))
175 assert result.exit_code == 0, (
176 f"sha256:-prefixed short ID must be accepted; got: {result.output}"
177 )
178
179
180 # ---------------------------------------------------------------------------
181 # muse snapshot export — bare hex must be rejected
182 # ---------------------------------------------------------------------------
183
184
185 class TestSnapshotExportBareHexRejected:
186 """snapshot export must reject bare hex."""
187
188 def test_full_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
189 repo = _init_repo(tmp_path)
190 out = tmp_path / "out.tar.gz"
191 result = runner.invoke(
192 cli,
193 ["snapshot", "export", _BARE_HEX_FULL, "--output", str(out)],
194 env=_env(repo),
195 )
196 assert result.exit_code != 0, "bare hex must be rejected by snapshot export"
197
198 def test_short_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
199 repo = _init_repo(tmp_path)
200 out = tmp_path / "out.tar.gz"
201 result = runner.invoke(
202 cli,
203 ["snapshot", "export", _BARE_HEX_SHORT, "--output", str(out)],
204 env=_env(repo),
205 )
206 assert result.exit_code != 0, "short bare hex must be rejected by snapshot export"
207
208 def test_prefixed_id_accepted(self, tmp_path: pathlib.Path) -> None:
209 repo = _init_repo(tmp_path)
210 sid, _ = _create_snapshot_and_commit(repo)
211 out = tmp_path / "out.tar.gz"
212 result = runner.invoke(
213 cli,
214 ["snapshot", "export", sid, "--output", str(out)],
215 env=_env(repo),
216 )
217 assert result.exit_code == 0, f"sha256: prefixed ID must be accepted; got: {result.output}"
218
219 def test_prefixed_short_id_accepted(self, tmp_path: pathlib.Path) -> None:
220 repo = _init_repo(tmp_path)
221 sid, _ = _create_snapshot_and_commit(repo)
222 short_prefixed = short_id(sid)
223 out = tmp_path / "out.tar.gz"
224 result = runner.invoke(
225 cli,
226 ["snapshot", "export", short_prefixed, "--output", str(out)],
227 env=_env(repo),
228 )
229 assert result.exit_code == 0, (
230 f"sha256:-prefixed short ID must be accepted; got: {result.output}"
231 )
232
233
234 # ---------------------------------------------------------------------------
235 # muse snapshot-diff — bare hex must be rejected for both refs
236 # ---------------------------------------------------------------------------
237
238
239 class TestSnapshotDiffBareHexRejected:
240 """snapshot-diff must reject bare hex in ref_a or ref_b position."""
241
242 def test_ref_a_bare_hex_full_rejected(self, tmp_path: pathlib.Path) -> None:
243 repo = _init_repo(tmp_path)
244 sid, _ = _create_snapshot_and_commit(repo)
245 result = runner.invoke(
246 cli, ["snapshot-diff", _BARE_HEX_FULL, sid], env=_env(repo)
247 )
248 assert result.exit_code != 0, "bare hex in ref_a position must be rejected"
249
250 def test_ref_b_bare_hex_full_rejected(self, tmp_path: pathlib.Path) -> None:
251 repo = _init_repo(tmp_path)
252 sid, _ = _create_snapshot_and_commit(repo)
253 result = runner.invoke(
254 cli, ["snapshot-diff", sid, _BARE_HEX_FULL], env=_env(repo)
255 )
256 assert result.exit_code != 0, "bare hex in ref_b position must be rejected"
257
258 def test_ref_a_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
259 repo = _init_repo(tmp_path)
260 sid, _ = _create_snapshot_and_commit(repo)
261 result = runner.invoke(
262 cli, ["snapshot-diff", _BARE_HEX_SHORT, sid], env=_env(repo)
263 )
264 assert result.exit_code != 0, "short bare hex in ref_a must be rejected"
265
266 def test_ref_b_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
267 repo = _init_repo(tmp_path)
268 sid, _ = _create_snapshot_and_commit(repo)
269 result = runner.invoke(
270 cli, ["snapshot-diff", sid, _BARE_HEX_SHORT], env=_env(repo)
271 )
272 assert result.exit_code != 0, "short bare hex in ref_b must be rejected"
273
274 def test_both_prefixed_full_ids_accepted(self, tmp_path: pathlib.Path) -> None:
275 repo = _init_repo(tmp_path)
276 oid_a = _obj(repo, b"version_a")
277 oid_b = _obj(repo, b"version_b")
278 sid_a = _snap(repo, {"f.txt": oid_a})
279 sid_b = _snap(repo, {"f.txt": oid_b})
280 result = runner.invoke(cli, ["snapshot-diff", sid_a, sid_b], env=_env(repo))
281 assert result.exit_code == 0, f"prefixed full IDs must be accepted; got: {result.output}"
282
283 def test_ref_a_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
284 repo = _init_repo(tmp_path)
285 oid_a = _obj(repo, b"version_a")
286 oid_b = _obj(repo, b"version_b")
287 sid_a = _snap(repo, {"f.txt": oid_a})
288 sid_b = _snap(repo, {"f.txt": oid_b})
289 short_a = short_id(sid_a)
290 result = runner.invoke(cli, ["snapshot-diff", short_a, sid_b], env=_env(repo))
291 assert result.exit_code == 0, (
292 f"sha256:-prefixed short ID in ref_a must be accepted; got: {result.output}"
293 )
294
295 def test_ref_b_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
296 repo = _init_repo(tmp_path)
297 oid_a = _obj(repo, b"version_a")
298 oid_b = _obj(repo, b"version_b")
299 sid_a = _snap(repo, {"f.txt": oid_a})
300 sid_b = _snap(repo, {"f.txt": oid_b})
301 short_b = short_id(sid_b)
302 result = runner.invoke(cli, ["snapshot-diff", sid_a, short_b], env=_env(repo))
303 assert result.exit_code == 0, (
304 f"sha256:-prefixed short ID in ref_b must be accepted; got: {result.output}"
305 )
306
307 def test_both_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
308 repo = _init_repo(tmp_path)
309 oid_a = _obj(repo, b"version_a")
310 oid_b = _obj(repo, b"version_b")
311 sid_a = _snap(repo, {"f.txt": oid_a})
312 sid_b = _snap(repo, {"f.txt": oid_b})
313 short_a = short_id(sid_a)
314 short_b = short_id(sid_b)
315 result = runner.invoke(cli, ["snapshot-diff", short_a, short_b], env=_env(repo))
316 assert result.exit_code == 0, (
317 f"both sha256:-prefixed short IDs must be accepted; got: {result.output}"
318 )
319
320 def test_branch_name_still_accepted(self, tmp_path: pathlib.Path) -> None:
321 """Non-hex branch names must continue to resolve normally."""
322 repo = _init_repo(tmp_path)
323 oid_a = _obj(repo, b"v1")
324 oid_b = _obj(repo, b"v2")
325 sid_a = _snap(repo, {"f.txt": oid_a})
326 sid_b = _snap(repo, {"f.txt": oid_b})
327 _commit(repo, sid_a, branch="main")
328 _commit(repo, sid_b, branch="dev")
329 result = runner.invoke(cli, ["snapshot-diff", "main", "dev"], env=_env(repo))
330 assert result.exit_code == 0, f"branch names must still resolve; got: {result.output}"
331
332 def test_head_still_accepted(self, tmp_path: pathlib.Path) -> None:
333 """HEAD must continue to resolve normally."""
334 repo = _init_repo(tmp_path)
335 oid = _obj(repo, b"v1")
336 sid = _snap(repo, {"f.txt": oid})
337 _commit(repo, sid)
338 result = runner.invoke(cli, ["snapshot-diff", "HEAD", "HEAD"], env=_env(repo))
339 assert result.exit_code == 0, f"HEAD must still resolve; got: {result.output}"
340
341
342 # ---------------------------------------------------------------------------
343 # muse verify-commit — bare hex must be rejected
344 # ---------------------------------------------------------------------------
345
346
347 class TestVerifyCommitBareHexRejected:
348 """verify-commit must reject bare 64-char hex commit IDs."""
349
350 def test_bare_64hex_rejected(self, tmp_path: pathlib.Path) -> None:
351 repo = _init_repo(tmp_path)
352 result = runner.invoke(
353 cli, ["verify-commit", _BARE_HEX_FULL], env=_env(repo)
354 )
355 assert result.exit_code != 0, "bare 64-char hex must be rejected by verify-commit"
356
357 def test_prefixed_id_not_found_is_not_bare_hex_error(self, tmp_path: pathlib.Path) -> None:
358 """A sha256:-prefixed ID that doesn't exist should fail with 'not found', not 'bare hex'."""
359 repo = _init_repo(tmp_path)
360 prefixed = long_id("b" * 64)
361 result = runner.invoke(cli, ["verify-commit", prefixed], env=_env(repo))
362 # Exit code != 0 is expected (commit doesn't exist), but the reason
363 # must NOT be a bare-hex rejection — 'sha256:' prefix is correct.
364 output_combined = result.output + (result.stderr or "")
365 # The word "bare" should not appear if the input was correctly prefixed.
366 assert "bare" not in output_combined.lower() or result.exit_code != 0
367
368
369 # ---------------------------------------------------------------------------
370 # muse read — bare hex must be rejected at the CLI boundary
371 # ---------------------------------------------------------------------------
372
373
374 class TestReadBareHexRejected:
375 """muse read must reject bare hex commit refs.
376
377 show uses resolve_commit_ref() — the CLI layer must catch bare hex before
378 that function is ever called. resolve_commit_ref() itself is internal and
379 is not the enforcement point.
380 """
381
382 def test_bare_full_hex_rejected(self, tmp_path: pathlib.Path) -> None:
383 repo = _init_repo(tmp_path)
384 result = runner.invoke(cli, ["read", _BARE_HEX_FULL], env=_env(repo))
385 assert result.exit_code != 0, "bare 64-char hex must be rejected by show"
386
387 def test_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
388 repo = _init_repo(tmp_path)
389 result = runner.invoke(cli, ["read", _BARE_HEX_SHORT], env=_env(repo))
390 assert result.exit_code != 0, "bare short hex must be rejected by show"
391
392 def test_prefixed_full_id_accepted(self, tmp_path: pathlib.Path) -> None:
393 repo = _init_repo(tmp_path)
394 oid = _obj(repo, b"content")
395 sid = _snap(repo, {"f.txt": oid})
396 cid = _commit(repo, sid)
397 result = runner.invoke(cli, ["read", cid], env=_env(repo))
398 assert result.exit_code == 0, f"sha256:-prefixed full commit ID must be accepted; got: {result.output}"
399
400 def test_branch_name_still_accepted(self, tmp_path: pathlib.Path) -> None:
401 repo = _init_repo(tmp_path)
402 oid = _obj(repo, b"content")
403 sid = _snap(repo, {"f.txt": oid})
404 _commit(repo, sid, branch="main")
405 result = runner.invoke(cli, ["read", "main"], env=_env(repo))
406 assert result.exit_code == 0, f"branch name must still resolve via show; got: {result.output}"
407
408 def test_head_still_accepted(self, tmp_path: pathlib.Path) -> None:
409 repo = _init_repo(tmp_path)
410 oid = _obj(repo, b"content")
411 sid = _snap(repo, {"f.txt": oid})
412 _commit(repo, sid, branch="main")
413 result = runner.invoke(cli, ["read", "HEAD"], env=_env(repo))
414 assert result.exit_code == 0, f"HEAD must still resolve via show; got: {result.output}"
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago