|
| 1 | +"""Pinned archives admit prepared assets, never arbitrary ignored host files.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import hashlib |
| 5 | +import json |
| 6 | +import subprocess |
| 7 | +import tarfile |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from benchmark.runtime.source import archive_source, checked_revision |
| 13 | +from loopx.presentation.chat_bundle import MANIFEST, source_inputs, validate_bundle |
| 14 | + |
| 15 | + |
| 16 | +@pytest.fixture |
| 17 | +def prepared_source(tmp_path): |
| 18 | + source = tmp_path / "source" |
| 19 | + source.mkdir() |
| 20 | + for args in [("init",), ("config", "user.name", "Fixture"), |
| 21 | + ("config", "user.email", "fixture@example.invalid")]: |
| 22 | + subprocess.run(["git", "-C", str(source), *args], check=True, |
| 23 | + capture_output=True) |
| 24 | + package = source / "apps/presentation/dashboard/package.json" |
| 25 | + package.parent.mkdir(parents=True) |
| 26 | + package.write_text("{}") |
| 27 | + (source / ".gitignore").write_text("loopx/web/chat/\nprivate/\n") |
| 28 | + subprocess.run(["git", "-C", str(source), "add", "apps", ".gitignore"], check=True) |
| 29 | + subprocess.run(["git", "-C", str(source), "commit", "-m", "source"], check=True, |
| 30 | + capture_output=True) |
| 31 | + revision = checked_revision(source) |
| 32 | + bundle = source / "loopx/web/chat" |
| 33 | + (bundle / "assets").mkdir(parents=True) |
| 34 | + files = {"index.html": '<script src="/chat/assets/main.js"></script>', |
| 35 | + "manifest.webmanifest": "{}", "assets/main.js": "export const ready = true;", |
| 36 | + "asset-retention.json": json.dumps({"schema_version": "loopx_chat_asset_retention_v1", |
| 37 | + "generations": [["assets/main.js"]]})} |
| 38 | + for name, data in files.items(): |
| 39 | + (bundle / name).write_text(data) |
| 40 | + (bundle / MANIFEST).write_text(json.dumps({ |
| 41 | + "schema_version": "loopx_chat_bundle_v1", "source_revision": revision, |
| 42 | + "source_files": source_inputs(source), "current_assets": ["assets/main.js"], |
| 43 | + "files": {name: hashlib.sha256(data.encode()).hexdigest() for name, data in files.items()}, |
| 44 | + })) |
| 45 | + (source / "private").mkdir() |
| 46 | + (source / "private/notes.txt").write_text("not an archive input") |
| 47 | + return source, revision, bundle |
| 48 | + |
| 49 | + |
| 50 | +def test_complete_archive_preserves_validated_delivery_without_private_files(prepared_source, tmp_path): |
| 51 | + source, revision, bundle = prepared_source |
| 52 | + output = tmp_path / "source.tar" |
| 53 | + archive_source(source, revision, output) |
| 54 | + extracted = tmp_path / "extracted" |
| 55 | + with tarfile.open(output) as archive: |
| 56 | + assert not any(name.startswith("private/") for name in archive.getnames()) |
| 57 | + archive.extractall(extracted, filter="data") |
| 58 | + validate_bundle(extracted / "loopx/web/chat", source_root=extracted) |
| 59 | + assert (extracted / "loopx/web/chat/assets/main.js").read_bytes() == (bundle / "assets/main.js").read_bytes() |
| 60 | + |
| 61 | + |
| 62 | +@pytest.mark.parametrize("problem", ["corrupt", "stale", "extra", "symlink", "root_symlink", "uncommitted_input"]) |
| 63 | +def test_invalid_prepared_delivery_fails_closed(prepared_source, tmp_path, problem): |
| 64 | + source, revision, bundle = prepared_source |
| 65 | + if problem == "corrupt": |
| 66 | + (bundle / "assets/main.js").write_text("corrupt") |
| 67 | + elif problem == "stale": |
| 68 | + (source / "apps/presentation/dashboard/package.json").write_text('{"changed": true}') |
| 69 | + elif problem == "extra": |
| 70 | + (bundle / "private.txt").write_text("not a declared asset") |
| 71 | + elif problem == "symlink": |
| 72 | + asset = bundle / "assets/main.js" |
| 73 | + asset.unlink() |
| 74 | + asset.symlink_to(source / "private/notes.txt") |
| 75 | + elif problem == "root_symlink": |
| 76 | + moved = bundle.rename(tmp_path / "bundle") |
| 77 | + bundle.symlink_to(moved, target_is_directory=True) |
| 78 | + else: |
| 79 | + untracked = source / "apps/presentation/dashboard/src/private.ts" |
| 80 | + untracked.parent.mkdir(parents=True) |
| 81 | + untracked.write_text("export const privateInput = true;") |
| 82 | + witness = bundle / MANIFEST |
| 83 | + manifest = json.loads(witness.read_text()) |
| 84 | + manifest["source_files"] = source_inputs(source) |
| 85 | + witness.write_text(json.dumps(manifest)) |
| 86 | + with pytest.raises(RuntimeError): |
| 87 | + archive_source(source, revision, tmp_path / "source.tar") |
| 88 | + |
| 89 | + |
| 90 | +def test_partial_archive_ignores_even_invalid_product_assets(prepared_source, tmp_path): |
| 91 | + source, revision, bundle = prepared_source |
| 92 | + (bundle / "private.txt").write_text("invalid delivery") |
| 93 | + output = tmp_path / "partial.tar" |
| 94 | + archive_source(source, revision, output, ("apps",)) |
| 95 | + with tarfile.open(output) as archive: |
| 96 | + assert all(name.startswith("apps") for name in archive.getnames()) |
0 commit comments