174 lines
7.4 KiB
Python
174 lines
7.4 KiB
Python
"""Archive lint (US07-01): the frozen CLI archive is complete, honest, and inert.
|
|
|
|
Freezing the donors is only worth something if three things stay true: the archive
|
|
still holds exactly the bytes it claims, it holds no secret, and production cannot
|
|
reach it. Each of those is a one-line mistake away — a helpful `sys.path` insert, a
|
|
copied `.env`, an edited "just this once" source — so each is asserted here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
ARCHIVE = REPO / "legacy_cli_archive"
|
|
SOURCES = ARCHIVE / "src"
|
|
LEDGER = ARCHIVE / "donor_ledger.yaml"
|
|
CHECKSUMS = ARCHIVE / "CHECKSUMS.sha256"
|
|
|
|
# Everything the concept requires an archive to carry (§3 "Donor-first CLI
|
|
# migration and archival"): the sources, their docs, a dependency lock, schema
|
|
# notes, a redacted sample configuration, the ledger, and recorded checksums.
|
|
REQUIRED_ARTIFACTS = (
|
|
"README.md",
|
|
"donor_ledger.yaml",
|
|
"CHECKSUMS.sha256",
|
|
"requirements-lock.txt",
|
|
"photo_analyzer.env.sample",
|
|
)
|
|
ARCHIVED_MODULES = ("photo_analyzer", "nsfwtag", "webapp", "nsfw_tag", "compare_models")
|
|
# Only the two suites that compare against the donors may put the archive on the
|
|
# import path; every other tree must not name it at all.
|
|
IMPORT_ALLOWED = {
|
|
REPO / "tests" / "characterization" / "conftest.py",
|
|
REPO / "tests" / "characterization" / "test_donor_ledger.py", # lints the ledger there
|
|
REPO / "tests" / "integration" / "test_safety_parity.py",
|
|
Path(__file__),
|
|
}
|
|
SECRET_PATTERNS = (
|
|
re.compile(r"sk-(?!REPLACE_WITH_YOUR_KEY)[A-Za-z0-9_\-]{16,}"),
|
|
re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), # Google API keys
|
|
re.compile(r"(?i)api[_-]?key\s*[=:]\s*['\"][A-Za-z0-9_\-]{16,}['\"]"),
|
|
)
|
|
|
|
|
|
def _archived_files() -> list[Path]:
|
|
return sorted(p for p in SOURCES.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
|
|
|
|
|
|
# ── completeness ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_archive_carries_every_required_artifact():
|
|
for artifact in REQUIRED_ARTIFACTS:
|
|
path = ARCHIVE / artifact
|
|
assert path.is_file(), f"archive is missing {artifact}"
|
|
assert path.stat().st_size > 0, f"archive artifact {artifact} is empty"
|
|
assert (SOURCES / "photo_analyzer.py").is_file()
|
|
assert (SOURCES / "nsfwtag" / "README.md").is_file(), "donor docs must be archived too"
|
|
assert (SOURCES / "webapp" / "README.md").is_file()
|
|
|
|
|
|
def test_every_ledger_source_is_present_in_the_archive():
|
|
rows = yaml.safe_load(LEDGER.read_text(encoding="utf-8"))["rows"]
|
|
for row in rows:
|
|
source = SOURCES / row["source"]["file"]
|
|
assert source.is_file(), f"{row['id']}: {row['source']['file']} is not archived"
|
|
|
|
|
|
def test_the_readme_records_provenance_and_the_no_import_rule():
|
|
readme = (ARCHIVE / "README.md").read_text(encoding="utf-8")
|
|
for expected in ("CHECKSUMS.sha256", "requirements-lock.txt", "donor_ledger.yaml"):
|
|
assert expected in readme, f"README does not point at {expected}"
|
|
assert "Schema notes" in readme, "the donor's schema must be documented"
|
|
assert "nsfw_scores.csv" in readme, "the CSV's fate must be documented"
|
|
|
|
|
|
# ── integrity ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_archived_source_matches_its_checksum():
|
|
"""A frozen archive that silently drifts is not evidence of anything."""
|
|
recorded = {}
|
|
for line in CHECKSUMS.read_text(encoding="utf-8").splitlines():
|
|
digest, _, rel = line.partition(" ")
|
|
if rel:
|
|
recorded[rel.strip()] = digest
|
|
actual = {
|
|
str(path.relative_to(ARCHIVE)): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
for path in _archived_files()
|
|
}
|
|
assert actual == recorded, "archived sources and CHECKSUMS.sha256 disagree"
|
|
|
|
|
|
# ── redaction ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_archive_contains_no_credential():
|
|
for path in [*_archived_files(), *(ARCHIVE / a for a in REQUIRED_ARTIFACTS)]:
|
|
if path.suffix in (".png", ".jpg", ".webp"):
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for pattern in SECRET_PATTERNS:
|
|
assert not pattern.search(text), f"possible secret in {path.relative_to(REPO)}"
|
|
|
|
|
|
def test_the_sample_configuration_is_a_placeholder_only():
|
|
sample = (ARCHIVE / "photo_analyzer.env.sample").read_text(encoding="utf-8")
|
|
assert "REDACTED" in sample
|
|
# Placeholders are not key-shaped, so neither a scanner nor a reader can
|
|
# mistake the sample for a credential.
|
|
assert "LLM_API_KEY=<" in sample
|
|
# No real env file, database, log, or CSV may ride along in the archive.
|
|
strays = [
|
|
p.name
|
|
for p in ARCHIVE.rglob("*")
|
|
if p.is_file() and p.suffix in (".env", ".db", ".sqlite", ".sqlite3", ".log", ".csv")
|
|
]
|
|
assert strays == [], f"unexpected runtime files archived: {strays}"
|
|
|
|
|
|
# ── inertness ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_production_code_never_imports_an_archived_module():
|
|
for path in (REPO / "photo_pipeline").rglob("*.py"):
|
|
text = path.read_text(encoding="utf-8")
|
|
for module in ARCHIVED_MODULES:
|
|
assert not re.search(rf"^\s*(import|from)\s+{module}\b", text, re.MULTILINE), \
|
|
f"{path.relative_to(REPO)} imports the archived {module}"
|
|
assert "legacy_cli_archive" not in text, \
|
|
f"{path.relative_to(REPO)} names the archive"
|
|
|
|
|
|
def test_only_the_parity_suites_put_the_archive_on_the_import_path():
|
|
for path in (REPO / "tests").rglob("*.py"):
|
|
if path in IMPORT_ALLOWED or "__pycache__" in path.parts:
|
|
continue
|
|
assert "legacy_cli_archive" not in path.read_text(encoding="utf-8"), \
|
|
f"{path.relative_to(REPO)} reaches into the archive"
|
|
|
|
|
|
def test_the_archived_modules_are_unimportable_from_a_clean_interpreter():
|
|
"""The real check: a fresh process with the repo on its path cannot load them."""
|
|
script = (
|
|
"import importlib.util, sys; "
|
|
f"sys.path.insert(0, {str(REPO)!r}); "
|
|
"print([m for m in "
|
|
f"{list(ARCHIVED_MODULES)!r}"
|
|
" if importlib.util.find_spec(m) is not None])"
|
|
)
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script], capture_output=True, text=True, cwd=str(REPO), check=True
|
|
)
|
|
assert result.stdout.strip() == "[]", f"still importable: {result.stdout.strip()}"
|
|
|
|
|
|
def test_the_application_starts_without_the_archive(tmp_path):
|
|
"""Nothing in the runtime path may need the frozen sources to exist."""
|
|
pytest.importorskip("fastapi")
|
|
from photo_pipeline.api.app import create_app
|
|
from photo_pipeline.config import Config
|
|
|
|
config = Config.from_env(
|
|
{"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"), "PHOTO_PIPELINE_LIBRARY_ROOTS": ""}
|
|
)
|
|
assert create_app(config) is not None
|