173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
"""Software-only tests for fixed RF-001 agent release archives."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import stat
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from tools.rf001_agent_update import (
|
|
CONTENT_MANIFEST_NAME,
|
|
RELEASE_SOURCE_FILES,
|
|
ReleaseValidationError,
|
|
build_release_archive,
|
|
canonical_json_bytes,
|
|
inspect_release_archive,
|
|
sign_update_manifest,
|
|
verify_update_manifest_signature,
|
|
)
|
|
|
|
|
|
def _source_tree(root: Path, *, agent_version: str = "2026.09.29.1") -> None:
|
|
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
content = f"# fixed fixture {index}\n"
|
|
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
|
content += f'AGENT_VERSION = "{agent_version}"\n'
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def test_release_roundtrip_uses_exact_allowlist(tmp_path: Path) -> None:
|
|
source = tmp_path / "source"
|
|
_source_tree(source)
|
|
package = tmp_path / "rf001-agent-2026.09.29.1.zip"
|
|
built = build_release_archive(
|
|
source,
|
|
package,
|
|
release_id="rf001-agent-2026.09.29.1",
|
|
agent_version="2026.09.29.1",
|
|
protocol_version=1,
|
|
)
|
|
extraction = tmp_path / "extracted"
|
|
checked = inspect_release_archive(
|
|
package,
|
|
expected_package_sha256=built.package_sha256,
|
|
expected_package_bytes=built.package_bytes,
|
|
expected_content_manifest_sha256=built.content_manifest_sha256,
|
|
extraction_root=extraction,
|
|
)
|
|
assert checked == built
|
|
assert {
|
|
path.relative_to(extraction).as_posix()
|
|
for path in extraction.rglob("*")
|
|
if path.is_file()
|
|
} == set(RELEASE_SOURCE_FILES) | {CONTENT_MANIFEST_NAME}
|
|
|
|
|
|
def test_update_manifest_rejects_bad_signature() -> None:
|
|
token = b"fixed-test-token-0123456789abcdef"
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"release_id": "rf001-agent-2026.09.29.1",
|
|
"package_sha256": "a" * 64,
|
|
}
|
|
signature = sign_update_manifest(token, manifest)
|
|
verify_update_manifest_signature(token, manifest, signature)
|
|
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
|
verify_update_manifest_signature(token, manifest, "0" * 64)
|
|
changed = dict(manifest, package_sha256="b" * 64)
|
|
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
|
verify_update_manifest_signature(token, changed, signature)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"unsafe_name",
|
|
(
|
|
"../outside.py",
|
|
"/absolute.py",
|
|
"C:/absolute.py",
|
|
"app\\tools\\backslash.py",
|
|
),
|
|
)
|
|
def test_release_rejects_unsafe_paths(tmp_path: Path, unsafe_name: str) -> None:
|
|
package = tmp_path / "unsafe.zip"
|
|
with zipfile.ZipFile(package, "w") as archive:
|
|
archive.writestr(unsafe_name, b"x")
|
|
# zipfile normalizes a backslash to a slash before it can be inspected;
|
|
# that spelling still cannot enter the fixed release allowlist.
|
|
with pytest.raises(
|
|
ReleaseValidationError,
|
|
match="unsafe|drive-like|not allowlisted|manifest is absent",
|
|
):
|
|
inspect_release_archive(package)
|
|
|
|
|
|
def test_release_rejects_symlink_and_duplicate_names(tmp_path: Path) -> None:
|
|
symlink_package = tmp_path / "symlink.zip"
|
|
with zipfile.ZipFile(symlink_package, "w") as archive:
|
|
info = zipfile.ZipInfo("app/tools/link.py")
|
|
info.create_system = 3
|
|
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
archive.writestr(info, b"target")
|
|
with pytest.raises(ReleaseValidationError, match="links"):
|
|
inspect_release_archive(symlink_package)
|
|
|
|
duplicate_package = tmp_path / "duplicate.zip"
|
|
with zipfile.ZipFile(duplicate_package, "w") as archive:
|
|
archive.writestr("app/tools/a.py", b"a")
|
|
archive.writestr("APP/TOOLS/A.PY", b"b")
|
|
with pytest.raises(ReleaseValidationError, match="duplicate"):
|
|
inspect_release_archive(duplicate_package)
|
|
|
|
|
|
def test_release_rejects_unsupported_protocol_and_sha(tmp_path: Path) -> None:
|
|
source = tmp_path / "source"
|
|
_source_tree(source)
|
|
package = tmp_path / "release.zip"
|
|
built = build_release_archive(
|
|
source,
|
|
package,
|
|
release_id="rf001-agent-2026.09.29.1",
|
|
agent_version="2026.09.29.1",
|
|
protocol_version=2,
|
|
)
|
|
assert built.protocol_version == 2
|
|
with pytest.raises(ReleaseValidationError, match="package SHA256"):
|
|
inspect_release_archive(package, expected_package_sha256="0" * 64)
|
|
|
|
|
|
def test_content_manifest_must_be_canonical(tmp_path: Path) -> None:
|
|
package = tmp_path / "noncanonical.zip"
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"release_id": "rf001-agent-2026.09.29.1",
|
|
"agent_version": "2026.09.29.1",
|
|
"protocol_version": 1,
|
|
"files": [],
|
|
}
|
|
assert canonical_json_bytes(manifest) != json.dumps(manifest).encode("utf-8")
|
|
with zipfile.ZipFile(package, "w") as archive:
|
|
archive.writestr(CONTENT_MANIFEST_NAME, json.dumps(manifest))
|
|
with pytest.raises(ReleaseValidationError, match="canonical"):
|
|
inspect_release_archive(package)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source_version", "requested_version"),
|
|
(
|
|
("2026.09.29.2", "2026.09.29.1"),
|
|
("2026.09.29.1", "2026.09.29.2"),
|
|
),
|
|
)
|
|
def test_release_builder_rejects_version_mismatch_before_artifact(
|
|
tmp_path: Path,
|
|
source_version: str,
|
|
requested_version: str,
|
|
) -> None:
|
|
source = tmp_path / "source"
|
|
_source_tree(source, agent_version=source_version)
|
|
package = tmp_path / "must-not-exist.zip"
|
|
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
|
build_release_archive(
|
|
source,
|
|
package,
|
|
release_id=f"rf001-agent-{requested_version}",
|
|
agent_version=requested_version,
|
|
protocol_version=1,
|
|
)
|
|
assert not package.exists()
|