mirror of https://github.com/razor-ai/soup.git
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""Tests for v0.60.0 Part B — `soup adapters sign / verify`.
|
|
|
|
Coverage:
|
|
- ``AdapterManifest`` / ``SignatureRecord`` frozen dataclasses
|
|
- ``compute_adapter_manifest`` deterministic SHA-256 over file list
|
|
- ``sign_adapter`` with UNSIGNED backend (live) + SIGSTORE deferred stub
|
|
- ``verify_adapter`` strict + lenient modes
|
|
- CLI smoke (sign / verify)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
|
|
def _make_adapter(tmp_path: Path, name: str = "adapter") -> Path:
|
|
target = tmp_path / name
|
|
target.mkdir()
|
|
(target / "adapter_model.safetensors").write_bytes(b"fake-weights-bytes")
|
|
(target / "adapter_config.json").write_text(
|
|
json.dumps({"peft_type": "LORA", "r": 8}), encoding="utf-8"
|
|
)
|
|
return target
|
|
|
|
|
|
class TestManifest:
|
|
def test_imports(self):
|
|
from soup_cli.utils.adapter_sign import (
|
|
AdapterManifest,
|
|
SignatureRecord,
|
|
compute_adapter_manifest,
|
|
sign_adapter,
|
|
verify_adapter,
|
|
)
|
|
assert callable(compute_adapter_manifest)
|
|
assert callable(sign_adapter)
|
|
assert callable(verify_adapter)
|
|
assert dataclasses.is_dataclass(AdapterManifest)
|
|
assert dataclasses.is_dataclass(SignatureRecord)
|
|
|
|
def test_manifest_deterministic(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
m1 = compute_adapter_manifest(str(adapter))
|
|
m2 = compute_adapter_manifest(str(adapter))
|
|
assert m1.adapter == m2.adapter
|
|
assert m1.files == m2.files
|
|
assert m1.merkle_root == m2.merkle_root
|
|
# Stable hash across runs.
|
|
assert len(m1.merkle_root) == 64
|
|
|
|
def test_manifest_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
outside = tmp_path.parent / "outside_adapter"
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
with pytest.raises(ValueError):
|
|
compute_adapter_manifest(str(outside))
|
|
|
|
def test_manifest_missing_dir(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
compute_adapter_manifest(str(tmp_path / "nope"))
|
|
|
|
def test_manifest_frozen(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
manifest = compute_adapter_manifest(str(adapter))
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
manifest.merkle_root = "evil" # type: ignore[misc]
|
|
|
|
def test_manifest_changes_when_weights_change(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
m1 = compute_adapter_manifest(str(adapter))
|
|
# Mutate weights file
|
|
(adapter / "adapter_model.safetensors").write_bytes(b"mutated")
|
|
m2 = compute_adapter_manifest(str(adapter))
|
|
assert m1.merkle_root != m2.merkle_root
|
|
|
|
def test_manifest_includes_all_files(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
manifest = compute_adapter_manifest(str(adapter))
|
|
names = {entry.name for entry in manifest.files}
|
|
assert "adapter_model.safetensors" in names
|
|
assert "adapter_config.json" in names
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
|
|
def test_manifest_symlink_rejected(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
target = tmp_path / "real_adapter"
|
|
target.mkdir()
|
|
link = tmp_path / "linked"
|
|
os.symlink(str(target), str(link))
|
|
from soup_cli.utils.adapter_sign import compute_adapter_manifest
|
|
|
|
with pytest.raises(ValueError):
|
|
compute_adapter_manifest(str(link))
|
|
|
|
|
|
class TestSign:
|
|
def test_sign_unsigned_backend(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter
|
|
|
|
record = sign_adapter(str(adapter), backend="unsigned")
|
|
assert record.backend == "unsigned"
|
|
assert record.signature == ""
|
|
sig_file = adapter / ".soup-signature.json"
|
|
assert sig_file.is_file()
|
|
payload = json.loads(sig_file.read_text(encoding="utf-8"))
|
|
assert payload["backend"] == "unsigned"
|
|
assert "merkle_root" in payload
|
|
|
|
def test_sign_sigstore_deferred(self, tmp_path, monkeypatch):
|
|
# Sigstore keyless signing is infra-blocked (needs OIDC + Fulcio/Rekor
|
|
# network); it stays a NotImplementedError after v0.71.2 #185.
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter
|
|
|
|
with pytest.raises(NotImplementedError, match="sigstore|infra-blocked"):
|
|
sign_adapter(str(adapter), backend="sigstore")
|
|
|
|
def test_sign_ed25519_now_live(self, tmp_path, monkeypatch):
|
|
# v0.71.2 #185 — ed25519 is now LIVE; without a key it raises a clear
|
|
# ValueError (no longer a deferred NotImplementedError).
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("SOUP_SIGNING_KEY", raising=False)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter
|
|
|
|
with pytest.raises(ValueError, match="private key"):
|
|
sign_adapter(str(adapter), backend="ed25519")
|
|
|
|
def test_sign_unknown_backend(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter
|
|
|
|
with pytest.raises(ValueError):
|
|
sign_adapter(str(adapter), backend="weird_unknown")
|
|
|
|
def test_sign_signature_record_frozen(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter
|
|
|
|
record = sign_adapter(str(adapter), backend="unsigned")
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
record.backend = "evil" # type: ignore[misc]
|
|
|
|
|
|
class TestVerify:
|
|
def test_verify_signed_adapter_passes(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter, verify_adapter
|
|
|
|
sign_adapter(str(adapter), backend="unsigned")
|
|
report = verify_adapter(str(adapter))
|
|
assert report.valid is True
|
|
assert report.backend == "unsigned"
|
|
|
|
def test_verify_unsigned_adapter_lenient(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
report = verify_adapter(str(adapter), strict=False)
|
|
assert report.valid is False
|
|
assert report.reason
|
|
# Lenient mode does not raise; just reports invalid.
|
|
|
|
def test_verify_unsigned_adapter_strict_raises(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
with pytest.raises(ValueError, match="(?i)signed|signature"):
|
|
verify_adapter(str(adapter), strict=True)
|
|
|
|
def test_verify_tampered_weights_fails(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import sign_adapter, verify_adapter
|
|
|
|
sign_adapter(str(adapter), backend="unsigned")
|
|
# Tamper with weights AFTER signing
|
|
(adapter / "adapter_model.safetensors").write_bytes(b"tampered-bytes")
|
|
report = verify_adapter(str(adapter))
|
|
assert report.valid is False
|
|
# Strict mode raises on tamper.
|
|
with pytest.raises(ValueError):
|
|
verify_adapter(str(adapter), strict=True)
|
|
|
|
def test_verify_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
with pytest.raises(ValueError):
|
|
verify_adapter(str(tmp_path.parent / "outside_adapter"))
|
|
|
|
def test_verify_unsigned_strict_distinct_message(self, tmp_path, monkeypatch):
|
|
"""Strict mode message must explicitly mention 'signed' (CI grep)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
try:
|
|
verify_adapter(str(adapter), strict=True)
|
|
pytest.fail("expected ValueError")
|
|
except ValueError as exc:
|
|
msg = str(exc).lower()
|
|
assert "signed" in msg or "signature" in msg
|
|
|
|
|
|
class TestSignVerifyCli:
|
|
def test_sign_help(self):
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["adapters", "sign", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
|
|
def test_verify_help(self):
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["adapters", "verify", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
|
|
def test_sign_then_verify_cli(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
rel = str(adapter.relative_to(tmp_path))
|
|
runner = CliRunner()
|
|
sign_result = runner.invoke(app, ["adapters", "sign", rel])
|
|
assert sign_result.exit_code == 0, (sign_result.output, repr(sign_result.exception))
|
|
verify_result = runner.invoke(app, ["adapters", "verify", rel])
|
|
assert verify_result.exit_code == 0, (verify_result.output, repr(verify_result.exception))
|
|
|
|
def test_verify_unsigned_strict_cli_exit3(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
rel = str(adapter.relative_to(tmp_path))
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["adapters", "verify", rel, "--strict"])
|
|
assert result.exit_code == 3
|
|
|
|
|
|
class TestSecurityReviewFixes:
|
|
"""Regression guards for the v0.60.0 Part B security-review fixes."""
|
|
|
|
def test_oversized_signature_rejected(self, tmp_path, monkeypatch):
|
|
"""Verify rejects a signature file > 16 MiB cap."""
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
sig = adapter / ".soup-signature.json"
|
|
# Write a 17 MiB JSON-shaped string — over the 16 MiB cap.
|
|
big = '{"x": "' + ("a" * (17 * 1024 * 1024)) + '"}'
|
|
sig.write_text(big, encoding="utf-8")
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
with pytest.raises(ValueError, match="(?i)exceeds"):
|
|
verify_adapter(str(adapter))
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
|
|
def test_symlinked_signature_file_rejected(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = _make_adapter(tmp_path)
|
|
# Place a symlink at the signature location.
|
|
target = tmp_path / "outside.json"
|
|
target.write_text('{"backend": "evil"}', encoding="utf-8")
|
|
os.symlink(str(target), str(adapter / ".soup-signature.json"))
|
|
from soup_cli.utils.adapter_sign import verify_adapter
|
|
|
|
with pytest.raises(ValueError, match="(?i)symlink"):
|
|
verify_adapter(str(adapter))
|
|
|
|
|
|
class TestSourceWiring:
|
|
def test_module_imports_clean(self):
|
|
import soup_cli.utils.adapter_sign as m
|
|
|
|
assert hasattr(m, "sign_adapter")
|
|
assert hasattr(m, "verify_adapter")
|
|
assert hasattr(m, "AdapterManifest")
|
|
|
|
def test_signature_filename_constant(self):
|
|
from soup_cli.utils.adapter_sign import _SIGNATURE_FILENAME
|
|
|
|
assert _SIGNATURE_FILENAME == ".soup-signature.json"
|