CANP-WP-0006 T05: HTTP registries, and one finding
--registry now accepts an http(s):// URL as well as a path, implemented with urllib so reference/ keeps PyYAML as its only dependency. This was the first real test of INTENT principle 10's claim that a hosted registry layers on without changing package semantics. Verified against the running service, almost everything survived the transport unaltered: identity and its ambiguity rules (a bare id in two registries returns 409 over HTTP just as it does locally), immutability of a published id@version (identical content accepted, changed content refused, version bump accepted), strict packaging, validation, and the index. A package published and then installed over HTTP was byte-identical to its source — diff -r clean — and its canonical-fidelity eval still passed after the round trip. One thing did not survive: a URL is not a registry. A filesystem registry IS one registry and section 20.1 names it from its directory; an HTTP service HOSTS SEVERAL behind one base URL. The address therefore cannot name the registry, so it must be named separately — --as when publishing, a qualified reference when installing. Recorded as section 20.4 rather than worked around silently in the client, because the gap is in the specification's list of registry kinds, not in the CLI. Publishing to an HTTP registry without --as fails with that explanation rather than guessing a registry name. Registry responses are treated as untrusted input (section 19): decode_files refuses path traversal, with a test. The wire shape round-trips binary content through base64, also tested. Reference tests 99 -> 105. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
parent
56b1708b58
commit
c076d8395e
6 changed files with 263 additions and 3 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -942,3 +943,68 @@ def test_explicit_registry_path_is_never_second_guessed(tmp_path: Path, monkeypa
|
|||
monkeypatch.setenv("CANNED_PROMPTS_HOME", str(tmp_path))
|
||||
(tmp_path / "registry").mkdir()
|
||||
cp.check_legacy_registry(tmp_path / "somewhere-else")
|
||||
|
||||
|
||||
# --- HTTP registries (§ 20.4) ---
|
||||
|
||||
def test_http_registry_detection() -> None:
|
||||
assert cp.is_http_registry("https://registry.example")
|
||||
assert cp.is_http_registry("http://localhost:8878")
|
||||
assert not cp.is_http_registry("/home/user/.canned-prompts/default")
|
||||
|
||||
|
||||
def test_encode_decode_round_trips_a_package(tmp_path: Path) -> None:
|
||||
"""The wire shape must reproduce the package byte for byte."""
|
||||
src = write_evaluated(tmp_path / "src", RUBRIC)
|
||||
(src / "assets").mkdir()
|
||||
(src / "assets" / "binary.dat").write_bytes(bytes(range(256)))
|
||||
manifest = cp.validate_package(src)
|
||||
|
||||
files = cp.encode_files(src, manifest)
|
||||
assert "base64" in files["assets/binary.dat"], "non-UTF-8 must not go as text"
|
||||
|
||||
out = tmp_path / "out"
|
||||
out.mkdir()
|
||||
cp.decode_files(files, out)
|
||||
for relative in cp.package_members(src, manifest):
|
||||
assert (out / relative).read_bytes() == (src / relative).read_bytes()
|
||||
|
||||
|
||||
def test_decode_refuses_path_traversal(tmp_path: Path) -> None:
|
||||
"""A registry is untrusted input (§ 19); it must not write outside the package."""
|
||||
with pytest.raises(cp.CannedPromptError, match="unsafe path"):
|
||||
cp.decode_files({"../escape.txt": {"text": "no"}}, tmp_path)
|
||||
|
||||
|
||||
def test_publish_to_http_requires_a_registry_name(tmp_path: Path, monkeypatch) -> None:
|
||||
"""§ 20.4: a URL hosts several registries, so it cannot name one."""
|
||||
src = write_pkg(tmp_path / "p", BASE + "inputs:\n - name: greeting\n required: false\n default: hi\n")
|
||||
args = argparse.Namespace(
|
||||
path=str(src), registry="https://registry.example", as_registry=None
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="needs --as NAME"):
|
||||
cp.cmd_publish(args)
|
||||
|
||||
|
||||
def test_http_errors_surface_the_registry_detail(monkeypatch) -> None:
|
||||
import urllib.error, io
|
||||
|
||||
def raise_conflict(*a, **k):
|
||||
raise urllib.error.HTTPError(
|
||||
"u", 409, "Conflict", {}, io.BytesIO(b'{"detail":"already exists"}')
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cp.urllib.request, "urlopen", raise_conflict)
|
||||
with pytest.raises(cp.CannedPromptError, match="registry refused \\(409\\): already exists"):
|
||||
cp.http_request("https://registry.example/packages")
|
||||
|
||||
|
||||
def test_unreachable_registry_is_reported_clearly(monkeypatch) -> None:
|
||||
import urllib.error
|
||||
|
||||
def raise_urlerror(*a, **k):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr(cp.urllib.request, "urlopen", raise_urlerror)
|
||||
with pytest.raises(cp.CannedPromptError, match="registry unreachable"):
|
||||
cp.http_request("https://registry.example/packages")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue