test_local_file_transport.py
python
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