gabriel / muse public
test_bare_hex_rejection.py python
407 lines 16.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 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([], sid, "test", committed_at.isoformat())
99 write_commit(
100 repo,
101 CommitRecord(
102 commit_id=cid,
103 repo_id="bare-hex-test",
104 branch=branch,
105 snapshot_id=sid,
106 message="test",
107 committed_at=committed_at,
108 author="tester",
109 parent_commit_id=None,
110 ),
111 )
112 ref = repo / ".muse" / "refs" / "heads" / branch
113 ref.write_text(cid, encoding="utf-8")
114 return cid
115
116
117 def _create_snapshot_and_commit(repo: pathlib.Path) -> tuple[str, str]:
118 """Return (snapshot_id, commit_id) for a one-file repo snapshot."""
119 oid = _obj(repo, b"hello world")
120 sid = _snap(repo, {"file.txt": oid})
121 cid = _commit(repo, sid)
122 return sid, cid
123
124
125 # ---------------------------------------------------------------------------
126 # muse snapshot read — bare hex must be rejected
127 # ---------------------------------------------------------------------------
128
129
130 class TestSnapshotReadBareHexRejected:
131 """snapshot read must reject bare hex, full or short."""
132
133 def test_full_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
134 repo = _init_repo(tmp_path)
135 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_FULL], env=_env(repo))
136 assert result.exit_code != 0, "bare full 64-char hex must be rejected"
137
138 def test_short_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
139 repo = _init_repo(tmp_path)
140 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_SHORT], env=_env(repo))
141 assert result.exit_code != 0, "bare short hex must be rejected"
142
143 def test_8_char_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
144 repo = _init_repo(tmp_path)
145 result = runner.invoke(cli, ["snapshot", "read", _INVALID_LOOK], env=_env(repo))
146 assert result.exit_code != 0, "any bare hex must be rejected"
147
148 def test_error_message_mentions_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
149 repo = _init_repo(tmp_path)
150 result = runner.invoke(cli, ["snapshot", "read", _BARE_HEX_SHORT], env=_env(repo))
151 assert result.exit_code != 0
152 assert "sha256:" in result.output.lower() or "sha256:" in (result.stderr or "").lower(), (
153 "error message must tell the user to use sha256: prefix"
154 )
155
156 def test_prefixed_full_id_accepted(self, tmp_path: pathlib.Path) -> None:
157 repo = _init_repo(tmp_path)
158 sid, _ = _create_snapshot_and_commit(repo)
159 result = runner.invoke(cli, ["snapshot", "read", sid], env=_env(repo))
160 assert result.exit_code == 0, f"sha256: prefixed full ID must be accepted; got: {result.output}"
161
162 def test_prefixed_short_id_accepted(self, tmp_path: pathlib.Path) -> None:
163 repo = _init_repo(tmp_path)
164 sid, _ = _create_snapshot_and_commit(repo)
165 # Short prefix: sha256: + first 12 hex chars
166 short_prefixed = short_id(sid)
167 result = runner.invoke(cli, ["snapshot", "read", short_prefixed], env=_env(repo))
168 assert result.exit_code == 0, (
169 f"sha256:-prefixed short ID must be accepted; got: {result.output}"
170 )
171
172
173 # ---------------------------------------------------------------------------
174 # muse snapshot export — bare hex must be rejected
175 # ---------------------------------------------------------------------------
176
177
178 class TestSnapshotExportBareHexRejected:
179 """snapshot export must reject bare hex."""
180
181 def test_full_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
182 repo = _init_repo(tmp_path)
183 out = tmp_path / "out.tar.gz"
184 result = runner.invoke(
185 cli,
186 ["snapshot", "export", _BARE_HEX_FULL, "--output", str(out)],
187 env=_env(repo),
188 )
189 assert result.exit_code != 0, "bare hex must be rejected by snapshot export"
190
191 def test_short_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
192 repo = _init_repo(tmp_path)
193 out = tmp_path / "out.tar.gz"
194 result = runner.invoke(
195 cli,
196 ["snapshot", "export", _BARE_HEX_SHORT, "--output", str(out)],
197 env=_env(repo),
198 )
199 assert result.exit_code != 0, "short bare hex must be rejected by snapshot export"
200
201 def test_prefixed_id_accepted(self, tmp_path: pathlib.Path) -> None:
202 repo = _init_repo(tmp_path)
203 sid, _ = _create_snapshot_and_commit(repo)
204 out = tmp_path / "out.tar.gz"
205 result = runner.invoke(
206 cli,
207 ["snapshot", "export", sid, "--output", str(out)],
208 env=_env(repo),
209 )
210 assert result.exit_code == 0, f"sha256: prefixed ID must be accepted; got: {result.output}"
211
212 def test_prefixed_short_id_accepted(self, tmp_path: pathlib.Path) -> None:
213 repo = _init_repo(tmp_path)
214 sid, _ = _create_snapshot_and_commit(repo)
215 short_prefixed = short_id(sid)
216 out = tmp_path / "out.tar.gz"
217 result = runner.invoke(
218 cli,
219 ["snapshot", "export", short_prefixed, "--output", str(out)],
220 env=_env(repo),
221 )
222 assert result.exit_code == 0, (
223 f"sha256:-prefixed short ID must be accepted; got: {result.output}"
224 )
225
226
227 # ---------------------------------------------------------------------------
228 # muse snapshot-diff — bare hex must be rejected for both refs
229 # ---------------------------------------------------------------------------
230
231
232 class TestSnapshotDiffBareHexRejected:
233 """snapshot-diff must reject bare hex in ref_a or ref_b position."""
234
235 def test_ref_a_bare_hex_full_rejected(self, tmp_path: pathlib.Path) -> None:
236 repo = _init_repo(tmp_path)
237 sid, _ = _create_snapshot_and_commit(repo)
238 result = runner.invoke(
239 cli, ["snapshot-diff", _BARE_HEX_FULL, sid], env=_env(repo)
240 )
241 assert result.exit_code != 0, "bare hex in ref_a position must be rejected"
242
243 def test_ref_b_bare_hex_full_rejected(self, tmp_path: pathlib.Path) -> None:
244 repo = _init_repo(tmp_path)
245 sid, _ = _create_snapshot_and_commit(repo)
246 result = runner.invoke(
247 cli, ["snapshot-diff", sid, _BARE_HEX_FULL], env=_env(repo)
248 )
249 assert result.exit_code != 0, "bare hex in ref_b position must be rejected"
250
251 def test_ref_a_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
252 repo = _init_repo(tmp_path)
253 sid, _ = _create_snapshot_and_commit(repo)
254 result = runner.invoke(
255 cli, ["snapshot-diff", _BARE_HEX_SHORT, sid], env=_env(repo)
256 )
257 assert result.exit_code != 0, "short bare hex in ref_a must be rejected"
258
259 def test_ref_b_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
260 repo = _init_repo(tmp_path)
261 sid, _ = _create_snapshot_and_commit(repo)
262 result = runner.invoke(
263 cli, ["snapshot-diff", sid, _BARE_HEX_SHORT], env=_env(repo)
264 )
265 assert result.exit_code != 0, "short bare hex in ref_b must be rejected"
266
267 def test_both_prefixed_full_ids_accepted(self, tmp_path: pathlib.Path) -> None:
268 repo = _init_repo(tmp_path)
269 oid_a = _obj(repo, b"version_a")
270 oid_b = _obj(repo, b"version_b")
271 sid_a = _snap(repo, {"f.txt": oid_a})
272 sid_b = _snap(repo, {"f.txt": oid_b})
273 result = runner.invoke(cli, ["snapshot-diff", sid_a, sid_b], env=_env(repo))
274 assert result.exit_code == 0, f"prefixed full IDs must be accepted; got: {result.output}"
275
276 def test_ref_a_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
277 repo = _init_repo(tmp_path)
278 oid_a = _obj(repo, b"version_a")
279 oid_b = _obj(repo, b"version_b")
280 sid_a = _snap(repo, {"f.txt": oid_a})
281 sid_b = _snap(repo, {"f.txt": oid_b})
282 short_a = short_id(sid_a)
283 result = runner.invoke(cli, ["snapshot-diff", short_a, sid_b], env=_env(repo))
284 assert result.exit_code == 0, (
285 f"sha256:-prefixed short ID in ref_a must be accepted; got: {result.output}"
286 )
287
288 def test_ref_b_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
289 repo = _init_repo(tmp_path)
290 oid_a = _obj(repo, b"version_a")
291 oid_b = _obj(repo, b"version_b")
292 sid_a = _snap(repo, {"f.txt": oid_a})
293 sid_b = _snap(repo, {"f.txt": oid_b})
294 short_b = short_id(sid_b)
295 result = runner.invoke(cli, ["snapshot-diff", sid_a, short_b], env=_env(repo))
296 assert result.exit_code == 0, (
297 f"sha256:-prefixed short ID in ref_b must be accepted; got: {result.output}"
298 )
299
300 def test_both_prefixed_short_accepted(self, tmp_path: pathlib.Path) -> None:
301 repo = _init_repo(tmp_path)
302 oid_a = _obj(repo, b"version_a")
303 oid_b = _obj(repo, b"version_b")
304 sid_a = _snap(repo, {"f.txt": oid_a})
305 sid_b = _snap(repo, {"f.txt": oid_b})
306 short_a = short_id(sid_a)
307 short_b = short_id(sid_b)
308 result = runner.invoke(cli, ["snapshot-diff", short_a, short_b], env=_env(repo))
309 assert result.exit_code == 0, (
310 f"both sha256:-prefixed short IDs must be accepted; got: {result.output}"
311 )
312
313 def test_branch_name_still_accepted(self, tmp_path: pathlib.Path) -> None:
314 """Non-hex branch names must continue to resolve normally."""
315 repo = _init_repo(tmp_path)
316 oid_a = _obj(repo, b"v1")
317 oid_b = _obj(repo, b"v2")
318 sid_a = _snap(repo, {"f.txt": oid_a})
319 sid_b = _snap(repo, {"f.txt": oid_b})
320 _commit(repo, sid_a, branch="main")
321 _commit(repo, sid_b, branch="dev")
322 result = runner.invoke(cli, ["snapshot-diff", "main", "dev"], env=_env(repo))
323 assert result.exit_code == 0, f"branch names must still resolve; got: {result.output}"
324
325 def test_head_still_accepted(self, tmp_path: pathlib.Path) -> None:
326 """HEAD must continue to resolve normally."""
327 repo = _init_repo(tmp_path)
328 oid = _obj(repo, b"v1")
329 sid = _snap(repo, {"f.txt": oid})
330 _commit(repo, sid)
331 result = runner.invoke(cli, ["snapshot-diff", "HEAD", "HEAD"], env=_env(repo))
332 assert result.exit_code == 0, f"HEAD must still resolve; got: {result.output}"
333
334
335 # ---------------------------------------------------------------------------
336 # muse verify-commit — bare hex must be rejected
337 # ---------------------------------------------------------------------------
338
339
340 class TestVerifyCommitBareHexRejected:
341 """verify-commit must reject bare 64-char hex commit IDs."""
342
343 def test_bare_64hex_rejected(self, tmp_path: pathlib.Path) -> None:
344 repo = _init_repo(tmp_path)
345 result = runner.invoke(
346 cli, ["verify-commit", _BARE_HEX_FULL], env=_env(repo)
347 )
348 assert result.exit_code != 0, "bare 64-char hex must be rejected by verify-commit"
349
350 def test_prefixed_id_not_found_is_not_bare_hex_error(self, tmp_path: pathlib.Path) -> None:
351 """A sha256:-prefixed ID that doesn't exist should fail with 'not found', not 'bare hex'."""
352 repo = _init_repo(tmp_path)
353 prefixed = long_id("b" * 64)
354 result = runner.invoke(cli, ["verify-commit", prefixed], env=_env(repo))
355 # Exit code != 0 is expected (commit doesn't exist), but the reason
356 # must NOT be a bare-hex rejection — 'sha256:' prefix is correct.
357 output_combined = result.output + (result.stderr or "")
358 # The word "bare" should not appear if the input was correctly prefixed.
359 assert "bare" not in output_combined.lower() or result.exit_code != 0
360
361
362 # ---------------------------------------------------------------------------
363 # muse read — bare hex must be rejected at the CLI boundary
364 # ---------------------------------------------------------------------------
365
366
367 class TestReadBareHexRejected:
368 """muse read must reject bare hex commit refs.
369
370 show uses resolve_commit_ref() — the CLI layer must catch bare hex before
371 that function is ever called. resolve_commit_ref() itself is internal and
372 is not the enforcement point.
373 """
374
375 def test_bare_full_hex_rejected(self, tmp_path: pathlib.Path) -> None:
376 repo = _init_repo(tmp_path)
377 result = runner.invoke(cli, ["read", _BARE_HEX_FULL], env=_env(repo))
378 assert result.exit_code != 0, "bare 64-char hex must be rejected by show"
379
380 def test_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
381 repo = _init_repo(tmp_path)
382 result = runner.invoke(cli, ["read", _BARE_HEX_SHORT], env=_env(repo))
383 assert result.exit_code != 0, "bare short hex must be rejected by show"
384
385 def test_prefixed_full_id_accepted(self, tmp_path: pathlib.Path) -> None:
386 repo = _init_repo(tmp_path)
387 oid = _obj(repo, b"content")
388 sid = _snap(repo, {"f.txt": oid})
389 cid = _commit(repo, sid)
390 result = runner.invoke(cli, ["read", cid], env=_env(repo))
391 assert result.exit_code == 0, f"sha256:-prefixed full commit ID must be accepted; got: {result.output}"
392
393 def test_branch_name_still_accepted(self, tmp_path: pathlib.Path) -> None:
394 repo = _init_repo(tmp_path)
395 oid = _obj(repo, b"content")
396 sid = _snap(repo, {"f.txt": oid})
397 _commit(repo, sid, branch="main")
398 result = runner.invoke(cli, ["read", "main"], env=_env(repo))
399 assert result.exit_code == 0, f"branch name must still resolve via show; got: {result.output}"
400
401 def test_head_still_accepted(self, tmp_path: pathlib.Path) -> None:
402 repo = _init_repo(tmp_path)
403 oid = _obj(repo, b"content")
404 sid = _snap(repo, {"f.txt": oid})
405 _commit(repo, sid, branch="main")
406 result = runner.invoke(cli, ["read", "HEAD"], env=_env(repo))
407 assert result.exit_code == 0, f"HEAD must still resolve via show; got: {result.output}"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago