gabriel / muse public
test_local_file_transport.py python
410 lines 15.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Comprehensive tests for LocalFileTransport — unit, integration, security, stress.
2
3 Coverage matrix
4 ---------------
5 Unit
6 _repo_root : valid URL → resolved path; bad scheme; missing .muse/
7 fetch_remote_info : reads repo.json + branch heads
8 make_transport : file:// → LocalFileTransport; https:// → HttpTransport
9
10 Integration (two real repos on disk)
11 push from A → B via file:// using push_stream
12 pull-equivalent: fetch_remote_info + fetch_stream from B after push
13 round-trip: push A→B, verify B branch heads, then fetch B→A-mirror
14
15 Stress
16 push bundle with 50 commits and 200 objects via push_stream
17 fetch 20 commits via fetch_stream
18 """
19
20 from __future__ import annotations
21 from collections.abc import Mapping
22
23 import base64
24 import datetime
25 import hashlib
26 import json
27 import os
28 import pathlib
29
30 import pytest
31
32 from muse._version import __version__
33 from muse.core.object_store import write_object
34 from muse.core.pack import build_mpack
35 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
36 from muse.core.store import (
37 CommitRecord,
38 SnapshotRecord,
39 get_all_branch_heads,
40 get_head_commit_id,
41 read_commit,
42 write_commit,
43 write_snapshot,
44 )
45
46 from muse.core.types import Manifest, blob_id
47 from muse.core.transport import (
48 HttpTransport,
49 LocalFileTransport,
50 TransportError,
51 make_transport,
52 )
53 from muse.core.paths import heads_dir, muse_dir, ref_path, repo_json_path
54
55
56 # ---------------------------------------------------------------------------
57 # Helpers
58 # ---------------------------------------------------------------------------
59
60
61
62
63 def _make_repo(path: pathlib.Path, branch: str = "main") -> pathlib.Path:
64 """Create a minimal initialised Muse repo at *path*."""
65 muse = muse_dir(path)
66 (muse / "refs" / "heads").mkdir(parents=True)
67 (muse / "objects").mkdir()
68 (muse / "commits").mkdir()
69 (muse / "snapshots").mkdir()
70 (muse / "repo.json").write_text(
71 json.dumps({"repo_id": f"repo-{path.name}", "schema_version": __version__, "domain": "midi", "default_branch": branch})
72 )
73 (muse / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
74 return path
75
76
77 def _add_commit(
78 root: pathlib.Path,
79 label: str,
80 branch: str = "main",
81 parent: str | None = None,
82 content: bytes = b"hello",
83 ) -> str:
84 """Write a commit with a real content-addressed ID and return it.
85
86 *label* is used only to derive a unique message so that different calls
87 with different labels produce different commit IDs even when all other
88 inputs are the same.
89 """
90 oid = blob_id(content)
91 write_object(root, oid, content)
92 manifest: Manifest = {"file.txt": oid}
93 snap_id = compute_snapshot_id(manifest)
94 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
95 write_snapshot(root, snap)
96 message = f"commit {label[:8]}"
97 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
98 parent_ids = [parent] if parent else []
99 real_cid = compute_commit_id(
100 parent_ids=parent_ids,
101 snapshot_id=snap_id,
102 message=message,
103 committed_at_iso=committed_at.isoformat(),
104 )
105 commit = CommitRecord(
106 repo_id=f"repo-{root.name}",
107 commit_id=real_cid,
108 branch=branch,
109 snapshot_id=snap_id,
110 message=message,
111 committed_at=committed_at,
112 parent_commit_id=parent,
113 )
114 write_commit(root, commit)
115 (ref_path(root, branch)).write_text(real_cid)
116 return real_cid
117
118
119 # ---------------------------------------------------------------------------
120 # Unit — _repo_root
121 # ---------------------------------------------------------------------------
122
123
124 class TestRepoRoot:
125 def test_valid_url_returns_resolved_path(self, tmp_path: pathlib.Path) -> None:
126 repo = _make_repo(tmp_path / "myrepo")
127 url = f"file://{repo}"
128 result = LocalFileTransport._repo_root(url)
129 assert result == repo.resolve()
130
131 def test_invalid_scheme_raises_transport_error(self, tmp_path: pathlib.Path) -> None:
132 with pytest.raises(TransportError, match="file://"):
133 LocalFileTransport._repo_root("https://hub.example.com/repos/r1")
134
135 def test_missing_muse_dir_raises_404(self, tmp_path: pathlib.Path) -> None:
136 with pytest.raises(TransportError) as exc_info:
137 LocalFileTransport._repo_root(f"file://{tmp_path}")
138 assert exc_info.value.status_code == 404
139 assert ".muse/" in str(exc_info.value)
140
141 def test_path_with_double_dots_normalized(self, tmp_path: pathlib.Path) -> None:
142 """resolve() must collapse .. so the check is on the canonical path."""
143 repo = _make_repo(tmp_path / "repo")
144 # Construct a URL with a harmless .. that stays inside the repo.
145 url = f"file://{repo}/subdir/../"
146 # The path resolves to the repo root — .muse/ exists there.
147 result = LocalFileTransport._repo_root(url)
148 assert result == repo.resolve()
149
150 def test_symlink_target_with_no_muse_is_rejected(self, tmp_path: pathlib.Path) -> None:
151 """A symlink that resolves to a dir without .muse/ must raise TransportError."""
152 target = tmp_path / "innocent"
153 target.mkdir()
154 link = tmp_path / "malicious_link"
155 link.symlink_to(target)
156 with pytest.raises(TransportError) as exc_info:
157 LocalFileTransport._repo_root(f"file://{link}")
158 assert exc_info.value.status_code == 404
159
160 def test_symlink_to_valid_repo_is_accepted(self, tmp_path: pathlib.Path) -> None:
161 """A symlink that resolves to a valid repo is accepted after resolve()."""
162 repo = _make_repo(tmp_path / "real_repo")
163 link = tmp_path / "alias"
164 link.symlink_to(repo)
165 result = LocalFileTransport._repo_root(f"file://{link}")
166 # Should return the canonical (resolved) path, not the symlink.
167 assert result == repo.resolve()
168
169
170 # ---------------------------------------------------------------------------
171 # Unit — fetch_remote_info
172 # ---------------------------------------------------------------------------
173
174
175 class TestFetchRemoteInfo:
176 def test_reads_repo_json_and_branch_heads(self, tmp_path: pathlib.Path) -> None:
177 repo = _make_repo(tmp_path / "remote")
178 cid = _add_commit(repo, "a" * 64)
179 t = LocalFileTransport()
180 info = t.fetch_remote_info(f"file://{repo}", signing=None)
181 assert info["repo_id"] == f"repo-{repo.name}"
182 assert info["domain"] == "midi"
183 assert info["default_branch"] == "main"
184 assert info["branch_heads"]["main"] == cid
185
186 def test_multiple_branches_returned(self, tmp_path: pathlib.Path) -> None:
187 repo = _make_repo(tmp_path / "remote")
188 cid_main = _add_commit(repo, "a" * 64, branch="main")
189 cid_dev = _add_commit(repo, "b" * 64, branch="dev")
190 t = LocalFileTransport()
191 info = t.fetch_remote_info(f"file://{repo}", signing=None)
192 assert info["branch_heads"]["main"] == cid_main
193 assert info["branch_heads"]["dev"] == cid_dev
194
195 def test_token_is_ignored(self, tmp_path: pathlib.Path) -> None:
196 """LocalFileTransport ignores the token arg — no auth for local repos."""
197 repo = _make_repo(tmp_path / "remote")
198 _add_commit(repo, "c" * 64)
199 t = LocalFileTransport()
200 info = t.fetch_remote_info(f"file://{repo}", signing="should-be-ignored")
201 assert info["repo_id"] == f"repo-{repo.name}"
202
203 def test_corrupted_repo_json_raises_transport_error(self, tmp_path: pathlib.Path) -> None:
204 repo = _make_repo(tmp_path / "bad")
205 (repo_json_path(repo)).write_text("NOT JSON")
206 t = LocalFileTransport()
207 with pytest.raises(TransportError, match="repo.json"):
208 t.fetch_remote_info(f"file://{repo}", signing=None)
209
210
211 # ---------------------------------------------------------------------------
212 # Unit — fetch_pack
213 # ---------------------------------------------------------------------------
214
215
216 # ---------------------------------------------------------------------------
217 # Unit — push_pack
218 # ---------------------------------------------------------------------------
219
220
221 # ---------------------------------------------------------------------------
222 # Security — branch name and path traversal
223 # ---------------------------------------------------------------------------
224
225
226 # ---------------------------------------------------------------------------
227 # make_transport factory
228 # ---------------------------------------------------------------------------
229
230
231 class TestMakeTransport:
232 def test_file_url_returns_local_transport(self) -> None:
233 assert isinstance(make_transport("file:///some/path"), LocalFileTransport)
234
235 def test_https_url_returns_http_transport(self) -> None:
236 assert isinstance(make_transport("https://hub.example.com/repos/r1"), HttpTransport)
237
238 def test_http_url_returns_http_transport(self) -> None:
239 assert isinstance(make_transport("http://hub.example.com/repos/r1"), HttpTransport)
240
241 def test_empty_url_returns_http_transport(self) -> None:
242 assert isinstance(make_transport(""), HttpTransport)
243
244
245 # ---------------------------------------------------------------------------
246 # Integration — full round-trip between two real repos
247 # ---------------------------------------------------------------------------
248
249
250 def _push(
251 t: LocalFileTransport,
252 url: str,
253 local: pathlib.Path,
254 commit_ids: list[str],
255 branch: str,
256 *,
257 have: list[str] | None = None,
258 force: bool = False,
259 ) -> Mapping[str, object]:
260 """Helper: build_mpack → push_stream. Returns the PushResult dict."""
261 bundle = build_mpack(local, commit_ids=commit_ids, have=have or [])
262 local_head = commit_ids[-1] if commit_ids else None
263 return t.push_stream(
264 url,
265 None,
266 objects=list(bundle.get("objects") or []),
267 commits=list(bundle.get("commits") or []),
268 snapshots=list(bundle.get("snapshots") or []),
269 branch=branch,
270 force=force,
271 have=have or [],
272 local_head=local_head,
273 )
274
275
276 class TestIntegrationRoundTrip:
277 def test_push_then_fetch_info(self, tmp_path: pathlib.Path) -> None:
278 """Push from local → remote via push_stream; branch heads must reflect the push."""
279 local = _make_repo(tmp_path / "local")
280 remote = _make_repo(tmp_path / "remote")
281 cid = _add_commit(local, blob_id(b"initial"), branch="main")
282
283 t = LocalFileTransport()
284 result = _push(t, f"file://{remote}", local, [cid], "main")
285 assert result["ok"] is True
286
287 info = t.fetch_remote_info(f"file://{remote}", None)
288 assert info["branch_heads"]["main"] == cid
289
290 def test_fetch_stream_after_push(self, tmp_path: pathlib.Path) -> None:
291 """After pushing A→B, fetch_stream from B must return the same commit."""
292 src = _make_repo(tmp_path / "src")
293 dst = _make_repo(tmp_path / "dst")
294 cid = _add_commit(src, blob_id(b"content"), branch="main")
295
296 t = LocalFileTransport()
297 _push(t, f"file://{dst}", src, [cid], "main")
298
299 fetched = t.fetch_stream(f"file://{dst}", None, want=[cid], have=[])
300 fetched_ids = {c["commit_id"] for c in (fetched.get("commits") or [])}
301 assert cid in fetched_ids
302
303 def test_multi_branch_round_trip(self, tmp_path: pathlib.Path) -> None:
304 """Push two branches via push_stream; remote must have both."""
305 local = _make_repo(tmp_path / "local")
306 remote = _make_repo(tmp_path / "remote")
307
308 cid_main = _add_commit(local, blob_id(b"main-commit"), branch="main")
309 cid_dev = _add_commit(local, blob_id(b"dev-commit"), branch="dev")
310
311 t = LocalFileTransport()
312 url = f"file://{remote}"
313
314 _push(t, url, local, [cid_main], "main")
315 _push(t, url, local, [cid_dev], "dev")
316
317 info = t.fetch_remote_info(url, None)
318 assert info["branch_heads"]["main"] == cid_main
319 assert info["branch_heads"]["dev"] == cid_dev
320
321 def test_incremental_push_is_fast_forward(self, tmp_path: pathlib.Path) -> None:
322 """Second push_stream whose parent is the remote tip must be accepted."""
323 local = _make_repo(tmp_path / "local")
324 remote = _make_repo(tmp_path / "remote")
325
326 cid1 = _add_commit(local, "commit-1", branch="main")
327
328 t = LocalFileTransport()
329 url = f"file://{remote}"
330
331 _push(t, url, local, [cid1], "main")
332
333 # Second commit with cid1 as parent.
334 cid2 = _add_commit(local, "commit-2", branch="main", parent=cid1)
335 result = _push(t, url, local, [cid2], "main", have=[cid1])
336
337 assert result["ok"] is True
338 assert get_head_commit_id(remote, "main") == cid2
339
340
341 # ---------------------------------------------------------------------------
342 # Stress — large bundle
343 # ---------------------------------------------------------------------------
344
345
346 class TestStress:
347 def test_push_large_bundle(self, tmp_path: pathlib.Path) -> None:
348 """Push a bundle with 50 commits and 200 distinct objects via push_stream."""
349 remote = _make_repo(tmp_path / "remote")
350 local = _make_repo(tmp_path / "local")
351
352 prev_cid: str | None = None
353 last_cid = ""
354 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
355 for i in range(50):
356 # Write 4 objects per commit (200 total).
357 manifest: Manifest = {}
358 for j in range(4):
359 blob = f"blob-{i}-{j}".encode()
360 oid = blob_id(blob)
361 write_object(local, oid, blob)
362 manifest[f"file_{i}_{j}.txt"] = oid
363 snap_id = compute_snapshot_id(manifest)
364 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
365 write_snapshot(local, snap)
366 message = f"commit {i}"
367 parent_ids = [prev_cid] if prev_cid else []
368 cid = compute_commit_id(
369 parent_ids=parent_ids,
370 snapshot_id=snap_id,
371 message=message,
372 committed_at_iso=committed_at.isoformat(),
373 )
374 commit = CommitRecord(
375 repo_id="repo-local",
376 commit_id=cid,
377 branch="main",
378 snapshot_id=snap_id,
379 message=message,
380 committed_at=committed_at,
381 parent_commit_id=prev_cid,
382 )
383 write_commit(local, commit)
384 prev_cid = cid
385 last_cid = cid
386
387 (heads_dir(local) / "main").write_text(last_cid)
388
389 t = LocalFileTransport()
390 result = _push(t, f"file://{remote}", local, [last_cid], "main")
391 assert result["ok"] is True
392 assert get_head_commit_id(remote, "main") == last_cid
393
394 def test_fetch_stream_large_bundle(self, tmp_path: pathlib.Path) -> None:
395 """Fetch from a remote with 20 commits via fetch_stream; verify all are returned."""
396 remote = _make_repo(tmp_path / "remote")
397 all_cids: list[str] = []
398 prev: str | None = None
399
400 for i in range(20):
401 cid = _add_commit(remote, f"remote-commit-{i}", parent=prev)
402 all_cids.append(cid)
403 prev = cid
404
405 last = all_cids[-1]
406 result = LocalFileTransport().fetch_stream(
407 f"file://{remote}", None, want=[last], have=[]
408 )
409 fetched_ids = {c["commit_id"] for c in (result.get("commits") or [])}
410 assert last in fetched_ids
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago