diff --git a/.github/scripts/smoke_ethos_full_candidate.py b/.github/scripts/smoke_ethos_full_candidate.py new file mode 100644 index 00000000..28d891ee --- /dev/null +++ b/.github/scripts/smoke_ethos_full_candidate.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Validate and smoke an ethos-full release-candidate archive on its target host.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tarfile +from pathlib import Path + + +INVENTORY_SCHEMA = "ethos.full_candidate_inventory.v1" +STATUS = "release_candidate_pending_target_smoke" +PUBLICATION = "not_publishable_pending_release_gates" + + +def run(command: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"smoke-ethos-full-candidate: error: {message}") + + +def required_regular(path: Path, label: str) -> None: + require(path.is_file() and not path.is_symlink(), f"missing regular {label}: {path}") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate_metadata(archive: Path, checksum: Path, inventory: Path) -> dict[str, object]: + for path, label in ((archive, "archive"), (checksum, "checksum"), (inventory, "inventory")): + required_regular(path, label) + digest = sha256_file(archive) + expected_checksum = f"{digest} {archive.name}\n" + require(checksum.read_text(encoding="utf-8") == expected_checksum, "checksum must bind the archive basename and sha256 canonically") + try: + record = json.loads(inventory.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"smoke-ethos-full-candidate: error: invalid inventory: {error}") from error + require(isinstance(record, dict), "inventory must be an object") + require( + set(record) == {"schema", "status", "publication", "artifact", "sha256", "size_bytes", "target"}, + "inventory has an unexpected shape", + ) + require(record["schema"] == INVENTORY_SCHEMA, "inventory schema is unsupported") + require(record["status"] == STATUS, "inventory status is not a release candidate") + require(record["publication"] == PUBLICATION, "inventory publication state is invalid") + require(record["artifact"] == archive.name, "inventory artifact does not match archive") + require(record["sha256"] == digest, "inventory sha256 does not match archive") + require(record["size_bytes"] == archive.stat().st_size, "inventory size does not match archive") + require(record["target"] in {"macos-arm64", "linux-x64"}, "inventory target is unsupported") + return record + + +def extract_candidate(archive: Path, extract_dir: Path) -> Path: + require(not extract_dir.exists(), f"extract directory already exists: {extract_dir}") + try: + with tarfile.open(archive, "r:gz") as bundle: + members = bundle.getmembers() + require(members, "archive has no members") + roots: set[str] = set() + for member in members: + path = Path(member.name) + require(not path.is_absolute() and all(part not in {"", ".", ".."} for part in path.parts), "archive contains an unsafe member path") + require(member.isfile(), f"archive member is not a regular file: {member.name}") + roots.add(path.parts[0]) + require(len(roots) == 1, "archive must contain exactly one top-level directory") + root_name = next(iter(roots)) + extract_dir.mkdir(parents=True) + root = extract_dir / root_name + root.mkdir() + for member in members: + destination = extract_dir / member.name + destination.parent.mkdir(parents=True, exist_ok=True) + source = bundle.extractfile(member) + require(source is not None, f"archive member is unreadable: {member.name}") + with destination.open("wb") as output: + shutil.copyfileobj(source, output) + destination.chmod(member.mode) + return root + except (OSError, tarfile.TarError) as error: + raise SystemExit(f"smoke-ethos-full-candidate: error: invalid archive: {error}") from error + + +def smoke(root: Path, expected_version: str, fixture: Path, record: dict[str, object], archive: Path) -> dict[str, object]: + required = ["ethos", "bin/ethos", "LICENSE", "NOTICE", "artifact-manifest.json"] + for name in required: + required_regular(root / name, f"payload {name}") + manifest = json.loads((root / "artifact-manifest.json").read_text(encoding="utf-8")) + require(manifest.get("target") == record["target"], "payload target does not match inventory") + runtime = manifest["pdfium"]["runtime_library_path"] + required_regular(root / runtime, f"PDFium runtime {runtime}") + env = dict(os.environ) + env["ETHOS_PDFIUM_LIBRARY_PATH"] = "/invalid/ambient/pdfium" + version = run([str(root / "ethos"), "--version"], env) + require(version.returncode == 0 and version.stdout.decode().strip() == expected_version, "unexpected ethos --version result") + doctor = run([str(root / "ethos"), "doctor", "--require-pdfium"], env) + require(doctor.returncode == 0, f"ethos doctor --require-pdfium failed: {doctor.stderr.decode()}") + evidence: dict[str, object] = { + "schema": "ethos.full_candidate_smoke.v1", + "artifact": archive.name, + "archive_sha256": record["sha256"], + "archive_size_bytes": record["size_bytes"], + "target": record["target"], + "version_stdout": expected_version, + "runtime_library_path": runtime, + "doctor_exit_code": doctor.returncode, + } + require(fixture.is_file(), f"missing parse fixture: {fixture}") + first = run([str(root / "ethos"), "doc", "parse", str(fixture), "--format", "json"], env) + second = run([str(root / "ethos"), "doc", "parse", str(fixture), "--format", "json"], env) + require(first.returncode == second.returncode == 0, "fixture parse failed") + require(first.stdout == second.stdout, "fixture parses were not byte-identical") + evidence["parse_stdout_sha256"] = hashlib.sha256(first.stdout).hexdigest() + return evidence + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--archive", required=True, type=Path) + parser.add_argument("--checksum", required=True, type=Path) + parser.add_argument("--inventory", required=True, type=Path) + parser.add_argument("--extract-dir", required=True, type=Path) + parser.add_argument("--expected-version", required=True) + parser.add_argument("--fixture", required=True, type=Path) + parser.add_argument("--out", type=Path) + args = parser.parse_args() + record = validate_metadata(args.archive, args.checksum, args.inventory) + root = extract_candidate(args.archive, args.extract_dir) + evidence = smoke(root, args.expected_version, args.fixture, record, args.archive) + if args.out: + args.out.write_text(json.dumps(evidence, sort_keys=True, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_ci_workflow.py b/.github/scripts/test_ci_workflow.py index b129f5e5..30a4d700 100644 --- a/.github/scripts/test_ci_workflow.py +++ b/.github/scripts/test_ci_workflow.py @@ -69,6 +69,7 @@ def test_package_integrity_and_registry_surface_gates_run_in_pr_ci(self) -> None "npm test --prefix packages/npm/ethos-pdf", "python3 .github/scripts/test_package_registry_source_consistency.py", "python3 .github/scripts/test_claims_gate_registry_surfaces.py", + "python3 .github/scripts/test_validate_npm_b_activation.py", "python3 .github/scripts/claims_gate.py", "python3 .github/scripts/public_boundary_claims_gate.py", "python3 .github/scripts/check_release_boundary_paths.py", diff --git a/.github/scripts/test_ethos_full_candidate.py b/.github/scripts/test_ethos_full_candidate.py index e2184ec8..b3b3bb6a 100644 --- a/.github/scripts/test_ethos_full_candidate.py +++ b/.github/scripts/test_ethos_full_candidate.py @@ -34,7 +34,7 @@ def setUp(self) -> None: self.profile = self.work / "profile.json" self.write_profile(sha256(self.runtime)) - def write_pdfium_archive(self, runtime: bytes, include_pdfium_notice: bool) -> None: + def write_pdfium_archive(self, runtime: bytes, include_pdfium_notice: bool, extras=None) -> None: files = { "LICENSE": b"PDFium package license\n", "lib/libpdfium.dylib": runtime, @@ -50,6 +50,8 @@ def write_pdfium_archive(self, runtime: bytes, include_pdfium_notice: bool) -> N info.size = len(data) info.mtime = 0 archive.addfile(info, io.BytesIO(data)) + for info, data in extras or []: + archive.addfile(info, io.BytesIO(data) if data is not None else None) def write_profile(self, runtime_hash: str) -> None: self.profile.write_text( @@ -120,6 +122,11 @@ def test_double_run_is_byte_identical_and_complete(self) -> None: (first / "ethos-full-test-macos-arm64.inventory.json").read_bytes(), (second / "ethos-full-test-macos-arm64.inventory.json").read_bytes(), ) + inventory = json.loads((first / "ethos-full-test-macos-arm64.inventory.json").read_text()) + self.assertEqual(first_archive.name, inventory["artifact"]) + self.assertEqual(sha256(first_archive.read_bytes()), inventory["sha256"]) + self.assertEqual(first_archive.stat().st_size, inventory["size_bytes"]) + self.assertEqual("macos-arm64", inventory["target"]) root = "ethos-full-test-macos-arm64" with tarfile.open(first_archive, "r:gz") as archive: @@ -137,8 +144,10 @@ def test_double_run_is_byte_identical_and_complete(self) -> None: self.assertIn(required, names) manifest = json.load(archive.extractfile(f"{root}/artifact-manifest.json")) launcher = archive.extractfile(f"{root}/ethos").read().decode("utf-8") - self.assertEqual("proposal_evidence_not_release_ready", manifest["status"]) - self.assertEqual("blocked_pending_adr_0015", manifest["publication"]) + self.assertEqual("release_candidate_pending_target_smoke", manifest["status"]) + self.assertEqual("not_publishable_pending_release_gates", manifest["publication"]) + self.assertEqual(sha256(self.binary.read_bytes()), manifest["input_sha256"]["ethos_binary"]) + self.assertEqual(sha256(self.pdfium_archive.read_bytes()), manifest["input_sha256"]["pdfium_archive"]) self.assertIn('ETHOS_PDFIUM_LIBRARY_PATH="$root/lib/libpdfium.dylib"', launcher) def test_hash_mismatch_fails_closed(self) -> None: @@ -159,6 +168,34 @@ def test_missing_notice_fails_closed(self) -> None: self.assertNotEqual(0, result.returncode) self.assertIn("must include licenses/pdfium.txt", result.stderr) + def test_link_and_special_members_fail_closed(self) -> None: + for name, member_type in (("licenses/link.txt", tarfile.SYMTYPE), ("licenses/device.txt", tarfile.CHRTYPE)): + with self.subTest(member_type=member_type): + info = tarfile.TarInfo(name) + info.type = member_type + info.linkname = "licenses/pdfium.txt" + self.write_pdfium_archive(self.runtime, True, [(info, None)]) + self.write_profile(sha256(self.runtime)) + result = subprocess.run(self.command(self.work / f"out-{member_type!r}"), cwd=ROOT, capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertIn("must be a regular file", result.stderr) + + def test_unsafe_duplicate_and_unexpected_members_fail_closed(self) -> None: + cases = [] + unsafe = tarfile.TarInfo("../escape.txt"); unsafe.size = 1 + cases.append((unsafe, b"x", "unsafe PDFium archive entry")) + duplicate = tarfile.TarInfo("LICENSE"); duplicate.size = 1 + cases.append((duplicate, b"x", "duplicate PDFium archive entry")) + unexpected = tarfile.TarInfo("payload.bin"); unexpected.size = 1 + cases.append((unexpected, b"x", "unexpected PDFium archive file")) + for info, data, diagnostic in cases: + with self.subTest(diagnostic=diagnostic): + self.write_pdfium_archive(self.runtime, True, [(info, data)]) + self.write_profile(sha256(self.runtime)) + result = subprocess.run(self.command(self.work / f"out-{diagnostic[:6]}"), cwd=ROOT, capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertIn(diagnostic, result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/test_execution_status.py b/.github/scripts/test_execution_status.py index f697ca1a..26c92ca8 100644 --- a/.github/scripts/test_execution_status.py +++ b/.github/scripts/test_execution_status.py @@ -34,18 +34,18 @@ def test_status_is_scoped_to_internal_continuation(self) -> None: text = status_text() self.assertIn( - "Status: v0.3.0 Rust library crates `ethos-doc-core`, `ethos-verify`, and `ethos-pdf` " + "Status: v0.4.0 Rust library crates `ethos-doc-core`, `ethos-verify`, and `ethos-pdf` " "are live on crates.io, and the Python `ethos-pdf` wheel is live on PyPI.", text, ) self.assertIn( - "The exact v0.3.0 public install wording packet is approved and closed out", + "The exact v0.4.0 public install wording packet is approved and closed out", text, ) self.assertIn("docs/validation/v0-3-0-public-install-wording-approval-decision-validation-2026-07-02.md", text) self.assertIn("docs/validation/v0-3-0-public-install-wording-closeout-validation-2026-07-02.md", text) self.assertIn("GitHub Release `v0.3.0`", text) - self.assertIn("npm `@docushell/ethos-pdf@0.3.0` is live on npm", text) + self.assertIn("npm `@docushell/ethos-pdf@0.4.0` is live on npm", text) self.assertIn("v0.3.0 npm publication closeout", text) self.assertIn("DocuShell integration remain blocked", text) self.assertIn("Internal Milestone D source-only closeout remains complete", text) @@ -106,15 +106,15 @@ def test_public_posture_boundary_remains_explicit(self) -> None: text, ) self.assertIn( - "v0.3.0 Rust library crates `ethos-doc-core`, `ethos-verify`, and `ethos-pdf` are live on crates.io", + "v0.4.0 Rust library crates `ethos-doc-core`, `ethos-verify`, and `ethos-pdf` are live on crates.io", text, ) self.assertIn("the Python `ethos-pdf` wheel is live on PyPI", text) - self.assertIn("npm `@docushell/ethos-pdf@0.3.0` is live on npm", text) - self.assertIn("The exact v0.3.0 public install wording packet is approved and closed out", text) - self.assertIn("GitHub Release `v0.3.0`", text) + self.assertIn("npm `@docushell/ethos-pdf@0.4.0` is live on npm", text) + self.assertIn("The exact v0.4.0 public install wording packet is approved and closed out", text) + self.assertIn("GitHub Release `v0.4.0`", text) self.assertIn("macOS arm64/Linux x64 CLI artifacts", text) - self.assertIn("`@docushell/ethos-pdf@0.3.0`", text) + self.assertIn("`@docushell/ethos-pdf@0.4.0`", text) self.assertIn("docs/validation/v0-3-0-publication-closeout-validation-2026-07-01.md", text) self.assertIn("ethos-doc-core", text) self.assertIn("ethos-verify", text) diff --git a/.github/scripts/test_npm_binary_package_scaffold.py b/.github/scripts/test_npm_binary_package_scaffold.py index 72a149f4..3c607e79 100644 --- a/.github/scripts/test_npm_binary_package_scaffold.py +++ b/.github/scripts/test_npm_binary_package_scaffold.py @@ -65,6 +65,8 @@ "scripts/prepare-vendor.js", "types/answer-release.d.ts", "types/citation-emission.d.ts", + "types/citation-emission-v2.d.ts", + "types/evidence-handle-context.d.ts", "types/index.d.ts", "types/verification-report.d.ts", "vendor/ethos-darwin-arm64", @@ -146,8 +148,8 @@ def test_package_docs_keep_pdfium_and_publication_boundaries(self) -> None: self.assertIn("does not bundle PDFium", text) self.assertIn("ETHOS_PDFIUM_LIBRARY_PATH", text) self.assertIn("QUICKSTART.md", text) - self.assertIn("current published npm package is `@docushell/ethos-pdf@0.3.0`", text) - self.assertIn("`ethos 0.3.0`", text) + self.assertIn("current published npm package is `@docushell/ethos-pdf@0.4.0`", text) + self.assertIn("`ethos 0.4.0`", text) self.assertIn("release-archive and extracted-executable SHA256 values", text) self.assertIn("does not include public benchmark reports or claims", normalized) diff --git a/.github/scripts/test_public_surface_posture.py b/.github/scripts/test_public_surface_posture.py index 316fbc2a..146c1f8f 100644 --- a/.github/scripts/test_public_surface_posture.py +++ b/.github/scripts/test_public_surface_posture.py @@ -51,12 +51,12 @@ def test_readme_status_matches_public_beta_evaluation_scope(self) -> None: self.assertIn("Python `ethos-pdf` wheel", normalized) self.assertIn("caller-provided PDFium", text) self.assertIn("release-scope work", text) - self.assertIn("cargo add ethos-doc-core@0.3.0", text) - self.assertIn("cargo add ethos-verify@0.3.0", text) - self.assertIn("cargo add ethos-pdf@0.3.0", text) - self.assertIn("python3 -m pip install ethos-pdf==0.3.0", text) - self.assertIn("npm install -g @docushell/ethos-pdf@0.3.0", text) - self.assertIn("GitHub Release `v0.3.0`", text) + self.assertIn("cargo add ethos-doc-core@0.4.0", text) + self.assertIn("cargo add ethos-verify@0.4.0", text) + self.assertIn("cargo add ethos-pdf@0.4.0", text) + self.assertIn("python3 -m pip install ethos-pdf==0.4.0", text) + self.assertIn("npm install -g @docushell/ethos-pdf@0.4.0", text) + self.assertIn("GitHub Release `v0.4.0`", text) self.assertNotIn("cargo add ethos-doc-core@0.2.0", text) self.assertNotIn("python3 -m pip install ethos-pdf==0.2.0", text) self.assertNotIn("npm install -g @docushell/ethos-pdf@0.2.1", text) diff --git a/.github/scripts/test_python_public_api_policy.py b/.github/scripts/test_python_public_api_policy.py index 9cafa3c3..09fb7978 100644 --- a/.github/scripts/test_python_public_api_policy.py +++ b/.github/scripts/test_python_public_api_policy.py @@ -43,6 +43,8 @@ "anchor", "app_answer_release_decision", "build_citation_emission", + "build_evidence_citation_emission", + "build_evidence_handle_context", "build_langchain_context", "build_llamaindex_context", "citation_json_bytes", @@ -50,6 +52,8 @@ "emit_langchain_citations", "emit_llamaindex_citations", "hydrate_citations", + "hydrate_evidence_citations", + "project_evidence_states", "parse_pdf_json", "parse_pdf_markdown", "parse_pdf_text", diff --git a/.github/scripts/test_release_artifact_workflow_prep.py b/.github/scripts/test_release_artifact_workflow_prep.py index 0160e960..d60d57a1 100644 --- a/.github/scripts/test_release_artifact_workflow_prep.py +++ b/.github/scripts/test_release_artifact_workflow_prep.py @@ -51,7 +51,7 @@ def test_workflow_generates_draft_artifacts_without_publication(self) -> None: self.assertNotIn('tar -C "$(dirname "$out")" -czf', text) self.assertIn("test_build_release_cli_archive.py", text) self.assertIn("smoke_release_cli_artifact.py", text) - self.assertIn('--expected-version "ethos 0.4.0"', text) + self.assertIn('--expected-version "ethos ${{ steps.version.outputs.value }}"', text) self.assertIn("--target \"${{ matrix.artifact_target }}\"", text) self.assertIn("*.smoke.json", text) self.assertIn("validate_release_artifact_inventory.py", text) @@ -74,9 +74,28 @@ def test_preflight_runs_release_scope_guards_before_artifacts(self) -> None: "test_pdfium_manual_setup_contract.py", "test_build_release_cli_archive.py", "test_windows_verify_candidate.py", + "test_ethos_full_candidate.py", + "test_smoke_ethos_full_candidate.py", ): self.assertIn(guard, text) + def test_ethos_full_candidate_job_is_target_limited_and_nonpublishing(self) -> None: + text = read(WORKFLOW) + start = text.index(" ethos-full-release-candidate:") + end = text.index(" windows-verify-draft-artifact:", start) + job = text[start:end] + self.assertIn("macos-arm64", job) + self.assertIn("linux-x64", job) + self.assertNotIn("windows", job) + self.assertIn("curl -fL --retry 3", job) + self.assertIn("profiles/ethos-deterministic-v1.json", job) + self.assertIn("build-ethos-full-candidate.py", job) + self.assertEqual(3, job.count("cmp target/release-artifacts/run1")) + self.assertIn("smoke_ethos_full_candidate.py", job) + self.assertIn("fixtures/synthetic/simple-text/document.pdf", job) + self.assertNotIn("gh release", job) + self.assertNotIn("publish", job) + def test_inventory_writer_and_validator_accept_draft_manifest(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) diff --git a/.github/scripts/test_smoke_ethos_full_candidate.py b/.github/scripts/test_smoke_ethos_full_candidate.py new file mode 100644 index 00000000..eee94050 --- /dev/null +++ b/.github/scripts/test_smoke_ethos_full_candidate.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import gzip +import hashlib +import io +import json +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SMOKE = ROOT / ".github/scripts/smoke_ethos_full_candidate.py" + + +class SmokeEthosFullCandidateTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.work = Path(self.temp.name) + self.fixture = self.work / "fixture.pdf" + self.fixture.write_bytes(b"fixture") + self.archive = self.work / "ethos-full-test-linux-x64.tar.gz" + self.checksum = self.archive.with_suffix(".tar.gz.sha256") + self.inventory = self.work / "ethos-full-test-linux-x64.inventory.json" + self.write_candidate() + + def tearDown(self) -> None: + self.temp.cleanup() + + def write_candidate(self, extra_name: str | None = None) -> None: + root = "ethos-full-test-linux-x64" + files = { + "LICENSE": b"LICENSE\n", "NOTICE": b"NOTICE\n", "lib/libpdfium.so": b"runtime", + "artifact-manifest.json": json.dumps({"target": "linux-x64", "pdfium": {"runtime_library_path": "lib/libpdfium.so"}}).encode(), + "bin/ethos": b'#!/bin/sh\nroot=$(CDPATH= cd -P -- "$(dirname "$0")/.." && pwd)\n[ "$ETHOS_PDFIUM_LIBRARY_PATH" = "$root/lib/libpdfium.so" ] || exit 9\ncase "$1" in --version) echo "ethos test";; doctor) exit 0;; doc) echo "{\\"document\\":true}";; esac\n', + "ethos": b'#!/bin/sh\nroot=$(CDPATH= cd -P -- "$(dirname "$0")" && pwd)\nexport ETHOS_PDFIUM_LIBRARY_PATH="$root/lib/libpdfium.so"\nexec "$root/bin/ethos" "$@"\n', + } + with self.archive.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as bundle: + for name, data in sorted(files.items()): + info = tarfile.TarInfo(f"{root}/{name}"); info.size = len(data); info.mode = 0o755 if name in {"ethos", "bin/ethos"} else 0o644; info.mtime = 0 + bundle.addfile(info, io.BytesIO(data)) + if extra_name: + info = tarfile.TarInfo(extra_name); info.size = 1; info.mtime = 0; bundle.addfile(info, io.BytesIO(b"x")) + digest = hashlib.sha256(self.archive.read_bytes()).hexdigest() + self.checksum.write_text(f"{digest} {self.archive.name}\n") + self.inventory.write_text(json.dumps({"schema": "ethos.full_candidate_inventory.v1", "status": "release_candidate_pending_target_smoke", "publication": "not_publishable_pending_release_gates", "artifact": self.archive.name, "sha256": digest, "size_bytes": self.archive.stat().st_size, "target": "linux-x64"})) + + def command(self, extract: Path, out: Path | None = None) -> list[str]: + command = ["python3", str(SMOKE), "--archive", str(self.archive), "--checksum", str(self.checksum), "--inventory", str(self.inventory), "--extract-dir", str(extract), "--expected-version", "ethos test", "--fixture", str(self.fixture)] + return command + (["--out", str(out)] if out else []) + + def test_smoke_validates_candidate_metadata_before_runtime(self) -> None: + out = self.work / "smoke.json" + result = subprocess.run(self.command(self.work / "extracted", out), capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + evidence = json.loads(out.read_text()) + self.assertEqual(self.archive.name, evidence["artifact"]) + self.assertEqual(hashlib.sha256(self.archive.read_bytes()).hexdigest(), evidence["archive_sha256"]) + self.assertIn("parse_stdout_sha256", evidence) + + def test_rejects_checksum_and_inventory_mismatches_before_extraction(self) -> None: + self.checksum.write_text("0" * 64 + f" {self.archive.name}\n") + result = subprocess.run(self.command(self.work / "bad-checksum"), capture_output=True, text=True) + self.assertNotEqual(0, result.returncode); self.assertIn("checksum", result.stderr); self.assertFalse((self.work / "bad-checksum").exists()) + self.write_candidate() + record = json.loads(self.inventory.read_text()); record["sha256"] = "0" * 64; self.inventory.write_text(json.dumps(record)) + result = subprocess.run(self.command(self.work / "bad-inventory"), capture_output=True, text=True) + self.assertNotEqual(0, result.returncode); self.assertIn("inventory sha256", result.stderr); self.assertFalse((self.work / "bad-inventory").exists()) + + def test_rejects_unsafe_archive_member_before_payload_execution(self) -> None: + self.write_candidate("../escape") + result = subprocess.run(self.command(self.work / "unsafe"), capture_output=True, text=True) + self.assertNotEqual(0, result.returncode); self.assertIn("unsafe member path", result.stderr) + + +if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/test_v0_4_0_version_activation.py b/.github/scripts/test_v0_4_0_version_activation.py deleted file mode 100644 index 7def117f..00000000 --- a/.github/scripts/test_v0_4_0_version_activation.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Guard the v0.4.0 source activation without changing published-install wording.""" - -from __future__ import annotations - -import json -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -VERSION = "0.4.0" -PUBLISHED = "0.3.0" - - -def read(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - -class V040VersionActivationTests(unittest.TestCase): - def test_release_metadata_is_activated_in_lockstep(self) -> None: - cargo = read("Cargo.toml") - cli = read("crates/ethos-cli/Cargo.toml") - lock = read("Cargo.lock") - self.assertIn(f'version = "{VERSION}"', cargo) - for dependency in ("ethos-core", "ethos-layout", "ethos-tables"): - self.assertIn(f'version = "{VERSION}"', next(line for line in cargo.splitlines() if line.startswith(dependency))) - for dependency in ("ethos-pdf", "ethos-verify", "ethos-grounding-opendataloader-json"): - self.assertIn(f'version = "{VERSION}"', next(line for line in cli.splitlines() if line.startswith(dependency))) - self.assertGreaterEqual(lock.count(f'version = "{VERSION}"'), 7) - self.assertIn(f'version = "{VERSION}"', read("pyproject.toml")) - self.assertIn(f'__version__ = "{VERSION}"', read("python/ethos_pdf/__init__.py")) - self.assertEqual(VERSION, json.loads(read("packages/npm/ethos-pdf/package.json"))["version"]) - npm_lock = json.loads(read("packages/npm/ethos-pdf/package-lock.json")) - self.assertEqual(VERSION, npm_lock["version"]) - self.assertEqual(VERSION, npm_lock["packages"][""]["version"]) - - def test_draft_artifact_workflow_uses_activated_version(self) -> None: - workflow = read(".github/workflows/release.yml") - self.assertEqual(2, workflow.count(f'--expected-version "ethos {VERSION}"')) - self.assertEqual(2, workflow.count(f"--version {VERSION}")) - - def test_published_install_wording_stays_on_current_release(self) -> None: - claims = read("docs/public-boundary-claims.json") - readme = read("README.md") - for expected in ( - f"cargo add ethos-doc-core@{PUBLISHED}", - f"python3 -m pip install ethos-pdf=={PUBLISHED}", - f"npm install -g @docushell/ethos-pdf@{PUBLISHED}", - ): - self.assertIn(expected, readme) - self.assertIn(expected, claims) - - def test_mcp_prototype_remains_excluded(self) -> None: - cargo = read("Cargo.toml") - workflow = read(".github/workflows/release.yml") - mcp = json.loads(read("packages/npm/ethos-mcp/package.json")) - self.assertNotIn("ethos-mcp", cargo) - self.assertNotIn("ethos-mcp", workflow) - self.assertEqual(PUBLISHED, mcp["version"]) - self.assertEqual(PUBLISHED, mcp["dependencies"]["@docushell/ethos-pdf"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_v0_5_0_version_activation.py b/.github/scripts/test_v0_5_0_version_activation.py new file mode 100644 index 00000000..1e5f7adb --- /dev/null +++ b/.github/scripts/test_v0_5_0_version_activation.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Guard v0.5.0 core activation while v0.4.0 remains publicly published.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VERSION = "0.5.0" +PUBLISHED_NPM_PAYLOAD = "0.4.0" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +class V050CoreVersionActivationTests(unittest.TestCase): + def test_core_release_metadata_is_activated_in_lockstep(self) -> None: + cargo = read("Cargo.toml") + cli = read("crates/ethos-cli/Cargo.toml") + lock = read("Cargo.lock") + self.assertIn(f'version = "{VERSION}"', cargo) + for dependency in ("ethos-core", "ethos-layout", "ethos-tables"): + self.assertIn(f'version = "{VERSION}"', next(line for line in cargo.splitlines() if line.startswith(dependency))) + for dependency in ("ethos-pdf", "ethos-verify", "ethos-grounding-opendataloader-json"): + self.assertIn(f'version = "{VERSION}"', next(line for line in cli.splitlines() if line.startswith(dependency))) + self.assertGreaterEqual(lock.count(f'version = "{VERSION}"'), 7) + self.assertIn(f'version = "{VERSION}"', read("pyproject.toml")) + self.assertIn(f'__version__ = "{VERSION}"', read("python/ethos_pdf/__init__.py")) + + def test_draft_artifact_workflows_derive_the_activated_version(self) -> None: + workflow = read(".github/workflows/release.yml") + self.assertEqual(2, workflow.count("tomllib.load(open('Cargo.toml','rb'))['workspace']['package']['version']")) + self.assertIn( + "tomllib.load(open(''Cargo.toml'',''rb''))[''workspace''][''package''][''version']", + workflow, + ) + self.assertEqual(2, workflow.count('--expected-version "ethos ${{ steps.version.outputs.value }}"')) + self.assertEqual(2, workflow.count('--version ${{ steps.version.outputs.value }}')) + + def test_public_install_wording_is_not_advanced_to_the_candidate(self) -> None: + claims = read("docs/public-boundary-claims.json") + readme = read("README.md") + self.assertNotIn("0.5.0", readme) + self.assertNotIn("0.5.0", claims) + + def test_npm_payload_remains_on_published_release_until_refreshed_from_core_a(self) -> None: + manifest = json.loads(read("packages/npm/ethos-pdf/vendor/manifest.json")) + package = json.loads(read("packages/npm/ethos-pdf/package.json")) + lock = json.loads(read("packages/npm/ethos-pdf/package-lock.json")) + self.assertEqual(PUBLISHED_NPM_PAYLOAD, manifest["cli_version"]) + self.assertEqual(PUBLISHED_NPM_PAYLOAD, package["version"]) + self.assertEqual(PUBLISHED_NPM_PAYLOAD, lock["version"]) + self.assertEqual(PUBLISHED_NPM_PAYLOAD, lock["packages"][""].get("version")) + + def test_mcp_prototype_remains_excluded(self) -> None: + cargo = read("Cargo.toml") + workflow = read(".github/workflows/release.yml") + mcp = json.loads(read("packages/npm/ethos-mcp/package.json")) + self.assertNotIn("ethos-mcp", cargo) + self.assertNotIn("ethos-mcp", workflow) + self.assertNotEqual(VERSION, mcp["version"]) + self.assertNotEqual(VERSION, mcp["dependencies"]["@docushell/ethos-pdf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_validate_npm_b_activation.py b/.github/scripts/test_validate_npm_b_activation.py new file mode 100644 index 00000000..fb602d64 --- /dev/null +++ b/.github/scripts/test_validate_npm_b_activation.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from validate_npm_b_activation import validate + + +class ValidateNpmBActivationTests(unittest.TestCase): + def fixture(self, version: str = "0.5.0") -> tuple[Path, Path]: + root = Path(tempfile.mkdtemp()) + package = root / "package" + vendor = package / "vendor" + vendor.mkdir(parents=True) + (package / "package.json").write_text(json.dumps({"version": version}), encoding="utf-8") + (package / "package-lock.json").write_text(json.dumps({"version": version, "packages": {"": {"version": version}}}), encoding="utf-8") + (vendor / "manifest.json").write_text(json.dumps({"cli_version": version}), encoding="utf-8") + targets = {} + for target in ("macos-arm64", "linux-x64"): + archive = root / f"{target}.tar.gz" + archive.write_bytes(target.encode()) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + checksum = root / f"{target}.sha256" + checksum.write_text(f"{digest} {archive.name}\n", encoding="utf-8") + inventory = root / f"{target}.inventory.json" + inventory.write_text(json.dumps({"schema": "ethos.full_candidate_inventory.v1", "status": "release_candidate_pending_target_smoke", "publication": "not_publishable_pending_release_gates", "target": target, "sha256": digest, "size_bytes": archive.stat().st_size}), encoding="utf-8") + smoke = root / f"{target}.smoke.json" + smoke.write_text(json.dumps({"schema": "ethos.full_candidate_smoke.v1", "target": target, "archive_sha256": digest, "archive_size_bytes": archive.stat().st_size, "version_stdout": version}), encoding="utf-8") + targets[target] = {"archive": archive.name, "checksum": checksum.name, "inventory": inventory.name, "smoke": smoke.name} + evidence = root / "evidence.json" + evidence.write_text(json.dumps({"schema": "ethos.npm_b_activation_evidence.v1", "core_version": version, "core_commit": "a" * 40, "targets": targets}), encoding="utf-8") + return evidence, package + + def test_accepts_complete_frozen_core_a_evidence(self) -> None: + evidence, package = self.fixture() + validate(evidence, package) + + def test_rejects_current_published_payload(self) -> None: + evidence, package = self.fixture(version="0.4.0") + with self.assertRaises(SystemExit): + validate(evidence, package) + + def test_rejects_unsafe_evidence_path(self) -> None: + evidence, package = self.fixture() + data = json.loads(evidence.read_text(encoding="utf-8")) + data["targets"]["linux-x64"]["archive"] = "../outside" + evidence.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaises(SystemExit): + validate(evidence, package) + + def test_rejects_unbound_archive_size(self) -> None: + evidence, package = self.fixture() + data = json.loads(evidence.read_text(encoding="utf-8")) + inventory = evidence.parent / data["targets"]["linux-x64"]["inventory"] + payload = json.loads(inventory.read_text(encoding="utf-8")) + payload["size_bytes"] += 1 + inventory.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaises(SystemExit): + validate(evidence, package) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_npm_b_activation.py b/.github/scripts/validate_npm_b_activation.py new file mode 100644 index 00000000..5a7d79d2 --- /dev/null +++ b/.github/scripts/validate_npm_b_activation.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Fail-closed validation of npm B inputs against frozen core-A evidence.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + + +SCHEMA = "ethos.npm_b_activation_evidence.v1" +TARGETS = ("macos-arm64", "linux-x64") +HEX64 = re.compile(r"^[0-9a-f]{64}$") + + +def fail(message: str) -> None: + raise SystemExit(f"validate-npm-b-activation: error: {message}") + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def load(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"invalid JSON {path}: {exc}") + if not isinstance(value, dict): + fail(f"expected object in {path}") + return value + + +def safe_relative(value: object, label: str) -> Path: + if not isinstance(value, str): + fail(f"{label} must be a relative path") + path = Path(value) + if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): + fail(f"{label} must be a safe relative path") + return path + + +def validate(evidence: Path, package_root: Path, expected_version: str = "0.5.0") -> None: + record = load(evidence) + if set(record) != {"schema", "core_version", "core_commit", "targets"}: + fail("evidence manifest has an unexpected shape") + if record["schema"] != SCHEMA or record["core_version"] != expected_version: + fail("evidence manifest schema or core version is invalid") + if not isinstance(record["core_commit"], str) or not re.fullmatch(r"[0-9a-f]{40,64}", record["core_commit"]): + fail("core_commit must be a full hexadecimal commit id") + targets = record["targets"] + if not isinstance(targets, dict) or set(targets) != set(TARGETS): + fail("evidence must contain exactly macos-arm64 and linux-x64 targets") + for target in TARGETS: + item = targets[target] + if not isinstance(item, dict) or set(item) != {"archive", "checksum", "inventory", "smoke"}: + fail(f"{target} evidence has an unexpected shape") + paths = {key: evidence.parent / safe_relative(item[key], f"{target}.{key}") for key in item} + for key, path in paths.items(): + if not path.is_file() or path.is_symlink(): + fail(f"missing regular {target} {key}: {path}") + inventory = load(paths["inventory"]) + if inventory.get("schema") != "ethos.full_candidate_inventory.v1" or inventory.get("target") != target: + fail(f"{target} inventory is not a target-smoke candidate record") + if inventory.get("status") != "release_candidate_pending_target_smoke" or inventory.get("publication") != "not_publishable_pending_release_gates": + fail(f"{target} inventory is publishable or not smoke-bound") + archive_hash = digest(paths["archive"]) + if inventory.get("sha256") != archive_hash or not HEX64.fullmatch(str(inventory.get("sha256", ""))): + fail(f"{target} inventory archive hash mismatch") + if inventory.get("size_bytes") != paths["archive"].stat().st_size: + fail(f"{target} inventory archive size mismatch") + checksum = paths["checksum"].read_text(encoding="utf-8") + if checksum != f"{archive_hash} {paths['archive'].name}\n": + fail(f"{target} checksum is not canonical") + smoke = load(paths["smoke"]) + if ( + smoke.get("schema") != "ethos.full_candidate_smoke.v1" + or smoke.get("target") != target + or smoke.get("archive_sha256") != archive_hash + or smoke.get("archive_size_bytes") != paths["archive"].stat().st_size + or smoke.get("version_stdout") != expected_version + ): + fail(f"{target} smoke evidence does not bind the frozen candidate") + package = load(package_root / "package.json") + lock = load(package_root / "package-lock.json") + manifest = load(package_root / "vendor" / "manifest.json") + root_lock = lock.get("packages", {}).get("") if isinstance(lock.get("packages"), dict) else None + if ( + package.get("version") != expected_version + or lock.get("version") != expected_version + or not isinstance(root_lock, dict) + or root_lock.get("version") != expected_version + or manifest.get("cli_version") != expected_version + ): + fail("npm package, package-lock, and vendor manifest must all be refreshed to core-A version") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", required=True, type=Path) + parser.add_argument("--package-root", required=True, type=Path) + parser.add_argument("--expected-version", default="0.5.0") + args = parser.parse_args() + validate(args.evidence, args.package_root, args.expected_version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f5fbf38..a9484bc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,8 +98,10 @@ jobs: run: python3 .github/scripts/test_python_public_api_policy.py - name: Ethos verify Action contract tests run: make ethos-verify-action-contract PYTHON=python3 - - name: v0.4.0 version activation tests - run: python3 .github/scripts/test_v0_4_0_version_activation.py + - name: v0.5.0 core version activation tests + run: python3 .github/scripts/test_v0_5_0_version_activation.py + - name: v0.5.0 npm B activation boundary tests + run: python3 .github/scripts/test_validate_npm_b_activation.py - name: Validation record source tests run: python3 .github/scripts/test_validation_record_source.py - name: frozen closed-lane record guards diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1892c29f..0beb0fc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,10 @@ jobs: run: python3 .github/scripts/test_build_release_cli_archive.py - name: Windows verify-only candidate contract tests run: python3 .github/scripts/test_windows_verify_candidate.py + - name: ethos-full candidate contract tests + run: python3 .github/scripts/test_ethos_full_candidate.py + - name: ethos-full smoke helper tests + run: python3 .github/scripts/test_smoke_ethos_full_candidate.py cli-draft-artifacts: needs: preflight @@ -47,6 +51,10 @@ jobs: - run: rustup show - name: build CLI run: cargo build --locked --release -p ethos-cli + - name: derive candidate version + id: version + shell: bash + run: echo "value=$(python3 -c \"import tomllib; print(tomllib.load(open('Cargo.toml','rb'))['workspace']['package']['version'])\")" >> "$GITHUB_OUTPUT" - name: assemble draft artifact shell: bash run: | @@ -69,7 +77,7 @@ jobs: run: | python3 .github/scripts/smoke_release_cli_artifact.py \ --artifact-dir "target/release-artifacts/ethos-${{ matrix.artifact_target }}" \ - --expected-version "ethos 0.4.0" \ + --expected-version "ethos ${{ steps.version.outputs.value }}" \ --target "${{ matrix.artifact_target }}" \ --out "target/release-artifacts/ethos-${{ matrix.artifact_target }}.smoke.json" - uses: actions/upload-artifact@v4 @@ -81,6 +89,47 @@ jobs: target/release-artifacts/*.inventory.json target/release-artifacts/*.smoke.json + ethos-full-release-candidate: + needs: preflight + strategy: + fail-fast: false + matrix: + include: + - artifact_target: macos-arm64 + os: macos-14 + - artifact_target: linux-x64 + os: ubuntu-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - run: rustup show + - name: build CLI + run: cargo build --locked --release -p ethos-cli + - name: download profile-pinned PDFium and build candidates twice + shell: bash + run: | + set -euo pipefail + version=$(python3 -c "import tomllib; print(tomllib.load(open('Cargo.toml','rb'))['workspace']['package']['version'])") + pdfium_archive="target/release-artifacts/pdfium-${{ matrix.artifact_target }}.tgz" + url=$(python3 -c "import json, urllib.parse; p=json.load(open('profiles/ethos-deterministic-v1.json')); b=p['backend']; base,tag=b['distribution']['release_url'].rsplit('/tag/',1); print(f\"{base}/download/{urllib.parse.quote(tag, safe='')}/{b['platform_artifacts']['${{ matrix.artifact_target }}']['name']}\")") + mkdir -p target/release-artifacts/run1 target/release-artifacts/run2 + curl -fL --retry 3 --output "$pdfium_archive" "$url" + for run in run1 run2; do + python3 scripts/build-ethos-full-candidate.py --target "${{ matrix.artifact_target }}" --version "$version" --ethos-binary target/release/ethos --pdfium-archive "$pdfium_archive" --out-dir "target/release-artifacts/$run" + done + cmp target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz target/release-artifacts/run2/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz + cmp target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz.sha256 target/release-artifacts/run2/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz.sha256 + cmp target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".inventory.json target/release-artifacts/run2/ethos-full-"$version"-"${{ matrix.artifact_target }}".inventory.json + python3 .github/scripts/smoke_ethos_full_candidate.py --archive target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz --checksum target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".tar.gz.sha256 --inventory target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".inventory.json --extract-dir target/release-artifacts/extracted --expected-version "ethos $version" --fixture fixtures/synthetic/simple-text/document.pdf --out target/release-artifacts/run1/ethos-full-"$version"-"${{ matrix.artifact_target }}".smoke.json + - uses: actions/upload-artifact@v4 + with: + name: ethos-full-candidate-${{ matrix.artifact_target }} + path: | + target/release-artifacts/run1/*.tar.gz + target/release-artifacts/run1/*.sha256 + target/release-artifacts/run1/*.inventory.json + target/release-artifacts/run1/*.smoke.json + windows-verify-draft-artifact: needs: preflight runs-on: windows-latest @@ -89,10 +138,14 @@ jobs: - run: rustup show - name: build Windows x64 CLI run: cargo build --locked --release -p ethos-cli + - name: derive candidate version + id: version + shell: pwsh + run: '"value=$(python -c \"import tomllib; print(tomllib.load(open(''Cargo.toml'',''rb''))[''workspace''][''package''][''version'])\")" | Out-File -FilePath $env:GITHUB_OUTPUT -Append' - name: assemble deterministic verify-only candidates twice run: | - python scripts/build-windows-verify-candidate.py --ethos-binary target/release/ethos.exe --version 0.4.0 --out-dir target/release-artifacts/run1 - python scripts/build-windows-verify-candidate.py --ethos-binary target/release/ethos.exe --version 0.4.0 --out-dir target/release-artifacts/run2 + python scripts/build-windows-verify-candidate.py --ethos-binary target/release/ethos.exe --version ${{ steps.version.outputs.value }} --out-dir target/release-artifacts/run1 + python scripts/build-windows-verify-candidate.py --ethos-binary target/release/ethos.exe --version ${{ steps.version.outputs.value }} --out-dir target/release-artifacts/run2 python -c "from pathlib import Path; a=Path('target/release-artifacts/run1/ethos-windows-x64.zip').read_bytes(); b=Path('target/release-artifacts/run2/ethos-windows-x64.zip').read_bytes(); assert a == b, 'Windows candidate archives differ'" - name: extract Windows candidate for smoke run: python -m zipfile -e target/release-artifacts/run1/ethos-windows-x64.zip target/release-artifacts/extracted @@ -101,7 +154,7 @@ jobs: python .github/scripts/write_release_artifact_inventory.py --artifact target/release-artifacts/run1/ethos-windows-x64.zip --checksum target/release-artifacts/run1/ethos-windows-x64.zip.sha256 --target windows-x64 --out target/release-artifacts/run1/ethos-windows-x64.inventory.json - name: smoke Windows verify-only artifact run: | - python .github/scripts/smoke_release_cli_artifact.py --artifact-dir target/release-artifacts/extracted/ethos-windows-x64 --expected-version "ethos 0.4.0" --target windows-x64 --out target/release-artifacts/run1/ethos-windows-x64.smoke.json + python .github/scripts/smoke_release_cli_artifact.py --artifact-dir target/release-artifacts/extracted/ethos-windows-x64 --expected-version "ethos ${{ steps.version.outputs.value }}" --target windows-x64 --out target/release-artifacts/run1/ethos-windows-x64.smoke.json - name: validate Windows draft inventory run: python .github/scripts/validate_release_artifact_inventory.py target/release-artifacts/run1/ethos-windows-x64.inventory.json - uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8abf94..b0bab05b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ ## Unreleased +- boundary-exception: reconcile v0.5.0 release-boundary CI metadata and DCO sign-offs for the + reviewed release implementation; no new public support boundary is opened. +- ci: index the v0.4.0 validation closeout and bind it to the existing source commit, and align + the Python public API policy with the released Evidence Handle Bridge exports. +- ci: repin the verification Action to the published v0.4.0 Linux release archive and binary. +- Fix Evidence Handle Bridge projection for real verification reports, stale evidence, structured state fields, strict contexts, and inert model prose. +- Render complete HTML proof diagnostics with canonical schema labels and expanded variant coverage. +- Cover the full verify-batch request boundary, foreign-grounding, config, and crop-rejection matrix. +- Alternate performance samples, compare repeated 32-process aggregates, and bind environment metadata. +- Reject unsafe, duplicate, unexpected, link, and special members in pinned PDFium archives. + +- Add a fail-closed validator binding future npm B activation to frozen v0.5.0 core-A target-smoke evidence. +- Run the npm B activation boundary contract in pull-request CI. +- Keep the npm package lockfile in the published-version hold until core-A refresh. +- Bind npm B evidence to candidate archive sizes as well as hashes. +- Document the operator sequence for refreshing and freezing npm B from core-A evidence. + +- Recompute source and citation fixture hashes when validating v0.5 performance evidence. + +- Add independent validation for v0.5 internal performance evidence and threshold derivations. + +- Make Evidence Handle Bridge state projection validate verification-report shape, status, and metadata fail-closed. + +- Reject empty v1 Evidence Handle Bridge contexts before hydration or state projection. + +- Reject unsafe URI-like and traversal-like HTML proof-report crop prefixes before link rendering. + +- Add an operator-only runner for v0.5 cold and batch verification timing evidence. + +- Activate v0.5.0 core Rust and Python candidate metadata in lockstep while retaining the + published v0.4.0 npm payload and public install wording until the frozen-A payload refresh. +- Reconcile stale public install wording to the closed v0.4.0 release baseline. +- Validate `ethos-full` archive, checksum, and inventory binding before target-host extraction. + +- verify: add black-box coverage for `verify-batch` canonical per-line equivalence, ordering, + repeat determinism, aggregate exit semantics, and invalid-input atomicity. +- packaging: promote the deterministic `ethos-full` builder from ADR proposal evidence to a + v0.5 release candidate pending required target smoke and release gates. +- examples: add a deterministic, model-free Evidence Handle Bridge walkthrough bound to a + recorded verification report. +- python: add Evidence Handle Bridge v1 trusted contexts and v2 structured model citations with + deterministic, fail-closed hydration while preserving citation-emission v1. +- npm: generate Evidence Handle Bridge context and model-output declarations, and add + fail-closed evidence-state projection for verification-report-bound application views. +- report: add deterministic, self-contained HTML proof reports with supported-schema checks, + escaped report content, and fail-closed crop-root validation. +- verify: add buffered, atomic `verify-batch` NDJSON verification against one validated source, + with canonical per-line reports and aggregate ungrounded exit semantics. +- release: reconcile the published v0.4.0 baseline, accept the bounded v0.5.0 + `ethos-full` exception, and establish the v0.5.0 four-deliverable release scope. + +## 0.4.0 - 2026-07-21 + - boundary-exception: refresh the v0.4.0 npm vendor payload from the final stable, byte-identical macOS arm64/Linux x64 CLI archives after removing the manifest checksum self-reference. - release: remove the CLI's compile-time npm vendor-manifest inclusion so the final CLI artifact diff --git a/Cargo.lock b/Cargo.lock index 76f3e54d..1c302372 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,7 +193,7 @@ dependencies = [ [[package]] name = "ethos-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "clap", "ethos-doc-core", @@ -210,7 +210,7 @@ dependencies = [ [[package]] name = "ethos-doc-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -221,7 +221,7 @@ dependencies = [ [[package]] name = "ethos-grounding-opendataloader-json" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ethos-doc-core", "serde", @@ -230,14 +230,14 @@ dependencies = [ [[package]] name = "ethos-layout" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ethos-doc-core", ] [[package]] name = "ethos-pdf" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ethos-doc-core", "serde", @@ -246,14 +246,14 @@ dependencies = [ [[package]] name = "ethos-tables" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ethos-doc-core", ] [[package]] name = "ethos-verify" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ethos-doc-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index dfef5a65..687b4044 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.87" license = "Apache-2.0" @@ -36,9 +36,9 @@ tempfile = "3" proptest = "1" # internal -ethos-core = { package = "ethos-doc-core", path = "crates/ethos-core", version = "0.4.0", default-features = false } -ethos-layout = { path = "crates/ethos-layout", version = "0.4.0" } -ethos-tables = { path = "crates/ethos-tables", version = "0.4.0" } +ethos-core = { package = "ethos-doc-core", path = "crates/ethos-core", version = "0.5.0", default-features = false } +ethos-layout = { path = "crates/ethos-layout", version = "0.5.0" } +ethos-tables = { path = "crates/ethos-tables", version = "0.5.0" } [profile.release] # Footprint discipline for the G2 gate (≤ 30 MB installed): strip + LTO + size-lean codegen. diff --git a/Makefile b/Makefile index f4d4ca15..eb5a2fe8 100644 --- a/Makefile +++ b/Makefile @@ -13,14 +13,14 @@ COMPARE_RENDERED_CROPS_LEFT ?= $(VERIFY_RENDERED_CROPS_OUT)/run1 COMPARE_RENDERED_CROPS_RIGHT ?= $(VERIFY_RENDERED_CROPS_OUT)/run2 LAYOUT_EVALUATOR_OUT ?= $(ROOT)/target/layout-evaluator-alpha -.PHONY: verify-alpha verify-alpha-tree rag-chunk-alpha security-report-alpha evidence-anchor-v1-contract citation-emission-v1-contract rag-framework-examples trust-benchmark-corpus ethos-full-candidate-contract windows-verify-candidate-contract ethos-verify-action-contract milestone-d-verify-citations-contract milestone-d-crop-element-contract milestone-d-sandbox-subprocess-contract milestone-d-internal-contracts milestone-e-prep release-candidate-prep v0-2-release-prep v0-3-release-prep v0-4-release-prep light-check package-publication-dry-run-smoke verify-rendered-crops compare-rendered-crops layout-evaluator-alpha python-surface-test milestone-b-internal-checks milestone-c-internal-checks release-hygiene release-advisory third-party-license-manifest release-notice-draft +.PHONY: verify-alpha verify-alpha-tree rag-chunk-alpha security-report-alpha evidence-anchor-v1-contract citation-emission-v1-contract rag-framework-examples trust-benchmark-corpus ethos-full-candidate-contract windows-verify-candidate-contract ethos-verify-action-contract milestone-d-verify-citations-contract milestone-d-crop-element-contract milestone-d-sandbox-subprocess-contract milestone-d-internal-contracts milestone-e-prep release-candidate-prep v0-2-release-prep v0-3-release-prep v0-5-release-prep light-check package-publication-dry-run-smoke verify-rendered-crops compare-rendered-crops layout-evaluator-alpha python-surface-test milestone-b-internal-checks milestone-c-internal-checks release-hygiene release-advisory third-party-license-manifest release-notice-draft .PHONY: milestone-d-capability-downgrade-contract .PHONY: milestone-d-opendataloader-adapter-shape-contract .PHONY: milestone-d-grounding-source-contract .PHONY: milestone-d-crop-element-surface-shape-contract .PHONY: milestone-d-claim-kind-boundary-contract .PHONY: app-answer-release-contract app-answer-release-demo app-answer-release-release-prep -.PHONY: frozen-record-guards release-state-check release-live-state-check registry-surface-check +.PHONY: frozen-record-guards release-state-check release-live-state-check registry-surface-check v0-5-performance-record v0-5-npm-b-activation-contract $(ETHOS_BIN): cargo build --locked -p ethos-cli @@ -77,6 +77,14 @@ trust-benchmark-corpus: $(ETHOS_BIN) ethos-full-candidate-contract: $(PYTHON) .github/scripts/test_ethos_full_candidate.py +v0-5-performance-record: + @test -n "$(V0_4_ETHOS_BIN)" && test -n "$(V0_5_ETHOS_BIN)" && test -n "$(V0_5_PERFORMANCE_OUT)" || (echo "set V0_4_ETHOS_BIN, V0_5_ETHOS_BIN, and V0_5_PERFORMANCE_OUT"; exit 2) + $(PYTHON) scripts/measure-v0-5-performance.py --baseline-bin "$(V0_4_ETHOS_BIN)" --candidate-bin "$(V0_5_ETHOS_BIN)" --out "$(V0_5_PERFORMANCE_OUT)" + $(PYTHON) scripts/validate-v0-5-performance.py --record "$(V0_5_PERFORMANCE_OUT)" --baseline-bin "$(V0_4_ETHOS_BIN)" --candidate-bin "$(V0_5_ETHOS_BIN)" --source schemas/examples/document.example.json --citations examples/verify/native_grounded_citations.json + +v0-5-npm-b-activation-contract: + $(PYTHON) .github/scripts/test_validate_npm_b_activation.py + windows-verify-candidate-contract: $(PYTHON) .github/scripts/test_windows_verify_candidate.py $(PYTHON) .github/scripts/test_release_artifact_workflow_prep.py @@ -142,7 +150,7 @@ v0-3-release-prep: $(PYTHON) .github/scripts/public_boundary_claims_gate.py git diff --check -v0-4-release-prep: +v0-5-release-prep: cargo build --locked --workspace cargo test --locked --workspace $(MAKE) verify-alpha PYTHON=$(PYTHON) @@ -151,7 +159,9 @@ v0-4-release-prep: npm test --prefix packages/npm/ethos-pdf $(PYTHON) .github/scripts/test_build_release_cli_archive.py $(PYTHON) .github/scripts/test_release_artifact_workflow_prep.py - $(PYTHON) .github/scripts/test_v0_4_0_version_activation.py + $(PYTHON) .github/scripts/test_v0_5_0_version_activation.py + $(MAKE) v0-5-npm-b-activation-contract PYTHON=$(PYTHON) + $(PYTHON) scripts/test_measure_v0_5_performance.py $(MAKE) release-hygiene PYTHON=$(PYTHON) $(PYTHON) .github/scripts/claims_gate.py $(PYTHON) .github/scripts/public_boundary_claims_gate.py diff --git a/README.md b/README.md index 48545abc..c7bc01a5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ > Ethos is a deterministic document evidence layer for source-grounded verification and > citation checking across native Ethos JSON and supported foreign parser outputs. The current > beta includes the GitHub source repository, Rust library crates `ethos-doc-core`, -> `ethos-verify`, and `ethos-pdf` at `0.3.0`, the Python `ethos-pdf` wheel at `0.3.0`, the npm -> `@docushell/ethos-pdf@0.3.0` package, and GitHub Release `v0.3.0` macOS arm64/Linux x64 CLI +> `ethos-verify`, and `ethos-pdf` at `0.4.0`, the Python `ethos-pdf` wheel at `0.4.0`, the npm +> `@docushell/ethos-pdf@0.4.0` package, and GitHub Release `v0.4.0` macOS arm64/Linux x64 CLI > artifacts. PDFium-backed commands use caller-provided PDFium through > `ETHOS_PDFIUM_LIBRARY_PATH`. > Current execution status and release-scope notes live in `docs/execution-status.md`; @@ -149,22 +149,22 @@ ethos --help To add the currently approved Rust library crates to another Rust project: ```bash -cargo add ethos-doc-core@0.3.0 -cargo add ethos-verify@0.3.0 -cargo add ethos-pdf@0.3.0 +cargo add ethos-doc-core@0.4.0 +cargo add ethos-verify@0.4.0 +cargo add ethos-pdf@0.4.0 ``` To install the Python wrapper from PyPI: ```bash -python3 -m pip install ethos-pdf==0.3.0 +python3 -m pip install ethos-pdf==0.4.0 ``` The Python wheel is a thin wrapper around a caller-provided local `ethos` CLI binary. It does not bundle the CLI or PDFium. Install or provide `ethos` separately, and keep `ETHOS_PDFIUM_LIBRARY_PATH` set for PDFium-backed commands. -The v0.3.0 Python wrapper includes JSON verification and evidence anchoring through that +The v0.4.0 Python wrapper includes JSON verification and evidence anchoring through that caller-provided CLI: ```python @@ -189,7 +189,7 @@ behavior. To install the npm CLI package on a supported first-release platform: ```bash -npm install -g @docushell/ethos-pdf@0.3.0 +npm install -g @docushell/ethos-pdf@0.4.0 ethos --version ``` @@ -200,7 +200,7 @@ platforms fail before invoking a binary. PDFium-backed commands fail until Run `ethos doctor` for local setup diagnostics. Run `ethos doctor --require-pdfium` after setting `ETHOS_PDFIUM_LIBRARY_PATH` to check whether the configured PDFium is usable by Ethos. -GitHub Release `v0.3.0` also provides evaluation CLI archives for macOS arm64 and Linux x64. +GitHub Release `v0.4.0` also provides evaluation CLI archives for macOS arm64 and Linux x64. ## 2-minute PDF parse quickstart diff --git a/actions/verify/README.md b/actions/verify/README.md index f7b2c0f7..bef9ab03 100644 --- a/actions/verify/README.md +++ b/actions/verify/README.md @@ -14,7 +14,7 @@ supplied source representation; it does not establish semantic truth. The Action writes `ethos-verification-report.json`, fails on ungrounded citations (CLI exit `1`), and fails on operational errors (CLI exit `>=2`). It supports GitHub-hosted Linux x64 runners; -other platforms fail explicitly. The Action downloads only the fixed v0.3.0 Linux x64 release +other platforms fail explicitly. The Action downloads only the fixed v0.4.0 Linux x64 release archive and verifies both its archive and executable SHA256 values before execution. Pin the Action itself to a full Ethos commit SHA. Marketplace publication is deferred; this diff --git a/actions/verify/action.yml b/actions/verify/action.yml index b6a5f85f..b538424e 100644 --- a/actions/verify/action.yml +++ b/actions/verify/action.yml @@ -20,14 +20,14 @@ inputs: runs: using: composite steps: - # Consumer path: intentionally install the last published CLI. Candidate-source - # verification is a separate repository CI responsibility until v0.4.0 publishes. + # Consumer path: install the last published CLI. Candidate-source verification is + # a separate repository CI responsibility. - name: Install pinned Ethos CLI shell: bash env: - ETHOS_ACTION_ARCHIVE_URL: https://github.com/docushell/ethos/releases/download/v0.3.0/ethos-linux-x64.tar.gz - ETHOS_ACTION_ARCHIVE_SHA256: b549ba5968e04b7679a8d3e879cd45d27f3e9a6fd226eee5c270a4e4f5c01405 - ETHOS_ACTION_BINARY_SHA256: b416993fc38e6f794611b8b71789ed85af18eb6aa63fef380d9ae7738661f154 + ETHOS_ACTION_ARCHIVE_URL: https://github.com/docushell/ethos/releases/download/v0.4.0/ethos-linux-x64.tar.gz + ETHOS_ACTION_ARCHIVE_SHA256: 616be562306d64a293554ca4695f19deb6e135dd328e88598a80e76f6f8fb3cd + ETHOS_ACTION_BINARY_SHA256: 2136dcd349a7b3f73f8df83a1b1e35819f9832043eb264b3eaea341697b739ed run: | python3 "$GITHUB_ACTION_PATH/install_cli.py" \ --url "$ETHOS_ACTION_ARCHIVE_URL" \ diff --git a/actions/verify/tests/test_action.py b/actions/verify/tests/test_action.py index 3e6b8a86..ec4d6f96 100644 --- a/actions/verify/tests/test_action.py +++ b/actions/verify/tests/test_action.py @@ -32,8 +32,8 @@ ACTION = Path(__file__).resolve().parents[1] ROOT = ACTION.parents[1] FIXTURES = Path(__file__).resolve().parent / "fixtures" -PUBLISHED_LINUX_ARCHIVE_SHA256 = "b549ba5968e04b7679a8d3e879cd45d27f3e9a6fd226eee5c270a4e4f5c01405" -PUBLISHED_LINUX_BINARY_SHA256 = "b416993fc38e6f794611b8b71789ed85af18eb6aa63fef380d9ae7738661f154" +PUBLISHED_LINUX_ARCHIVE_SHA256 = "616be562306d64a293554ca4695f19deb6e135dd328e88598a80e76f6f8fb3cd" +PUBLISHED_LINUX_BINARY_SHA256 = "2136dcd349a7b3f73f8df83a1b1e35819f9832043eb264b3eaea341697b739ed" sys.path.insert(0, str(ACTION)) import install_cli # noqa: E402 diff --git a/crates/ethos-cli/Cargo.toml b/crates/ethos-cli/Cargo.toml index dbae3242..d67ecfd4 100644 --- a/crates/ethos-cli/Cargo.toml +++ b/crates/ethos-cli/Cargo.toml @@ -17,9 +17,9 @@ path = "src/main.rs" ethos-core = { workspace = true, features = ["full", "crop-element"] } ethos-layout = { workspace = true } ethos-tables = { workspace = true } -ethos-pdf = { path = "../ethos-pdf", version = "0.4.0" } -ethos-verify = { path = "../ethos-verify", version = "0.4.0" } -ethos-grounding-opendataloader-json = { path = "../../adapters/grounding/opendataloader-json", version = "0.4.0" } +ethos-pdf = { path = "../ethos-pdf", version = "0.5.0" } +ethos-verify = { path = "../ethos-verify", version = "0.5.0" } +ethos-grounding-opendataloader-json = { path = "../../adapters/grounding/opendataloader-json", version = "0.5.0" } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/ethos-cli/src/cmd/mod.rs b/crates/ethos-cli/src/cmd/mod.rs index 9f7c3141..0250df7d 100644 --- a/crates/ethos-cli/src/cmd/mod.rs +++ b/crates/ethos-cli/src/cmd/mod.rs @@ -20,5 +20,6 @@ pub(crate) mod doc; pub(crate) mod doctor; pub(crate) mod evidence; pub(crate) mod rag; +pub(crate) mod report; pub(crate) mod security; pub(crate) mod verify; diff --git a/crates/ethos-cli/src/cmd/report.rs b/crates/ethos-cli/src/cmd/report.rs new file mode 100644 index 00000000..fc28864d --- /dev/null +++ b/crates/ethos-cli/src/cmd/report.rs @@ -0,0 +1,111 @@ +use ethos_core::verify_types::{VerificationReport, HARDENED_VERIFICATION_SCHEMA_VERSION}; +use serde::Serialize; + +use crate::{read_file_limited, Failure, ReportHtmlArgs}; + +pub(crate) fn html(args: ReportHtmlArgs) -> Result<(), Failure> { + let report: VerificationReport = serde_json::from_slice(&read_file_limited( + &args.input, + crate::default_max_input_bytes(), + )?) + .map_err(|_| Failure::Usage("input is not a supported verification report".to_string()))?; + if report.schema_version != ethos_core::SCHEMA_VERSION + && report.schema_version != HARDENED_VERIFICATION_SCHEMA_VERSION + { + return Err(Failure::Usage( + "verification report schema_version is not supported".to_string(), + )); + } + let crop_root = args + .crop_root + .as_deref() + .map(validate_crop_root) + .transpose()?; + let bytes = render(&report, crop_root.as_deref()).into_bytes(); + std::fs::write(&args.out, bytes) + .map_err(|_| Failure::Usage(format!("cannot write output: {}", args.out.display()))) +} + +fn validate_crop_root(value: &str) -> Result { + if value.is_empty() + || value.split('/').any(|segment| { + segment.is_empty() + || matches!(segment, "." | "..") + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) + { + return Err(Failure::Usage( + "--crop-root must be a safe relative prefix".to_string(), + )); + } + Ok(value.to_string()) +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn safe_crop_basename(value: &str) -> Option<&str> { + (!value.is_empty() && !value.contains(['/', '\\', '?', '#']) && !value.contains("..")) + .then_some(value) +} + +fn wire_label(value: &T) -> String { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn json_text(value: &T) -> String { + escape(&serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())) +} + +fn render(report: &VerificationReport, crop_root: Option<&str>) -> String { + let proof = report.proof_summary(); + let mut out = String::from("Ethos proof report

Ethos proof report

"); + out.push_str(&format!("

Proof status: {}; request certified: {}.

", if report.all_evidence_grounded {"ok"} else {"bad"}, proof.proof_status.as_str(), report.all_evidence_grounded)); + out.push_str(&format!("
Document fingerprint
{}
Fingerprint stale
{}
Verification config hash
{}
Grounding parser
{} {}
Grounding capabilities
{}
Capability limits
{}
Proof limitations
{}
Report warnings
{}
Dispersion
{}
", escape(report.document_fingerprint.as_deref().unwrap_or("unavailable")), report.fingerprint_stale, escape(&report.verification_config_sha256), escape(&report.grounding.parser.name), escape(&report.grounding.parser.version), json_text(&report.grounding.capabilities), json_text(&report.capability_limits), json_text(&proof.proof_limitations.iter().map(|value| value.as_str()).collect::>()), json_text(&report.warnings), json_text(&report.dispersion))); + out.push_str("

Ethos verifies citation grounding, not semantic truth, answer relevance, completeness, or synthesis quality.

Checks

"); + for check in &report.checks { + out.push_str(&format!("

{}: {}

Status: {}; match method: {}; semantic unverified: {}.

Reason: {}

Warnings: {}

Claim: {}

Locator: {}

", escape(&check.id), escape(&wire_label(&check.claim.kind)), escape(&wire_label(&check.status)), escape(&wire_label(&check.match_method)), check.semantic_unverified, escape(&check.reason.as_ref().map(wire_label).unwrap_or_else(|| "none".to_string())), json_text(&check.warnings), escape(check.claim.text.as_deref().unwrap_or("(presence)")), escape(&serde_json::to_string(&check.claim.citation).unwrap_or_default()))); + if let Some(evidence) = &check.evidence { + out.push_str(&format!( + "

Evidence: {}

Page: {}; bbox: {}

", + escape(evidence.text.as_deref().unwrap_or("unavailable")), + escape(evidence.page.as_deref().unwrap_or("unavailable")), + escape( + &evidence + .bbox + .map(|bbox| format!("{:?}", bbox)) + .unwrap_or_else(|| "unavailable".to_string()) + ) + )); + if let Some(crop_ref) = evidence.crop_ref.as_deref() { + if let (Some(root), Some(name)) = (crop_root, safe_crop_basename(crop_ref)) { + out.push_str(&format!( + "

Crop: {}

", + escape(root), + escape(name), + escape(name) + )); + } else { + out.push_str(&format!( + "

Crop unavailable in this standalone report: {}

", + escape(crop_ref) + )); + } + } + } + out.push_str("
"); + } + out.push_str("\n"); + out +} diff --git a/crates/ethos-cli/src/cmd/verify.rs b/crates/ethos-cli/src/cmd/verify.rs index 6e889f7d..da17a113 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -16,6 +16,7 @@ use std::collections::BTreeMap; use std::collections::HashSet; +use std::io::Write as _; use std::path::{Path, PathBuf}; use ethos_core::crop_element::{CropElementDescriptor, CropElementRendering}; @@ -40,7 +41,7 @@ use crate::cmd::crop_artifacts::{ }; use crate::{ default_max_input_bytes, read_document, read_file_limited, write_output, Failure, VerifyArgs, - VerifyOutputFormat, + VerifyBatchArgs, VerifyOutputFormat, }; pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { @@ -123,6 +124,151 @@ pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { write_report(args.out, args.format, report, args.fail_on_ungrounded) } +/// Verify an all-or-nothing NDJSON batch. Source/configuration validation happens once before +/// any report is rendered or output is written. +pub(crate) fn verify_batch(args: VerifyBatchArgs) -> Result<(), Failure> { + let max_input_bytes = default_max_input_bytes(); + let citations = parse_batch_citations(&args.citations_ndjson, max_input_bytes)?; + let config = load_batch_config(args.config.as_deref(), max_input_bytes)?; + if config + .evidence + .as_ref() + .is_some_and(|evidence| evidence.include_crops) + { + return Err(Failure::Usage( + "verify-batch does not support crop evidence".to_string(), + )); + } + for citation in &citations { + validate_citation_input(citation, &config)?; + } + let config_value = + serde_json::to_value(&config).map_err(|e| EthosError::internal(e.to_string()))?; + let config_sha256 = + ethos_core::c14n::sha256_hex(&config_value).map_err(|e| EthosError::internal(e.message))?; + + let reports = match args.grounding.as_deref() { + None => { + let document = read_document(&args.input)?; + batch_reports(&document, citations, &config, &config_sha256) + } + Some("opendataloader-json") => { + let bytes = read_file_limited(&args.input, max_input_bytes)?; + let text = String::from_utf8(bytes) + .map_err(|_| Failure::Usage("grounding input is not UTF-8".to_string()))?; + let source = OdlJsonSource::from_json_str(&text) + .map_err(|e| Failure::Usage(format!("opendataloader-json adapter: {e}")))?; + batch_reports(&source, citations, &config, &config_sha256) + } + Some(other) => { + return Err(Failure::Usage(format!( + "unknown grounding adapter '{other}' (available: opendataloader-json)" + ))) + } + }; + + let mut output = Vec::new(); + let mut any_ungrounded = false; + for report in reports { + any_ungrounded |= !report.all_evidence_grounded; + let mut line = verification_report_json_bytes(&report)?; + line.pop(); // The ordinary report framing newline becomes NDJSON framing below. + output.extend_from_slice(&line); + output.push(b'\n'); + } + write_batch_output(args.out, &output)?; + if args.fail_on_ungrounded && any_ungrounded { + return Err(Failure::Ungrounded); + } + Ok(()) +} + +fn write_batch_output(out: Option, bytes: &[u8]) -> Result<(), Failure> { + let Some(path) = out else { + return write_output(None, bytes); + }; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|_| Failure::Usage(format!("cannot write output: {}", path.display())))?; + temporary + .write_all(bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|_| Failure::Usage(format!("cannot write output: {}", path.display())))?; + temporary + .persist(&path) + .map_err(|_| Failure::Usage(format!("cannot write output: {}", path.display())))?; + Ok(()) +} + +fn parse_batch_citations(path: &Path, max_input_bytes: u64) -> Result, Failure> { + let bytes = read_file_limited(path, max_input_bytes)?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| Failure::Usage("citations NDJSON is not UTF-8".to_string()))?; + if text.is_empty() { + return Err(Failure::Usage( + "citations NDJSON must contain 1 to 1024 requests".to_string(), + )); + } + let mut citations = Vec::new(); + for (index, line) in text.split('\n').enumerate() { + let line = line.strip_suffix('\r').unwrap_or(line); + if line.is_empty() { + if index + 1 == text.split('\n').count() { + continue; + } + return Err(Failure::Usage( + "citations NDJSON must not contain blank lines".to_string(), + )); + } + let citation: CitationInput = serde_json::from_str(line).map_err(|_| { + Failure::Usage(format!( + "citations NDJSON line {} does not match the canonical citation input shape", + index + 1 + )) + })?; + citations.push(citation); + } + if citations.is_empty() || citations.len() > 1024 { + return Err(Failure::Usage( + "citations NDJSON must contain 1 to 1024 requests".to_string(), + )); + } + Ok(citations) +} + +fn load_batch_config( + path: Option<&Path>, + max_input_bytes: u64, +) -> Result { + let config = match path { + Some(path) => { + serde_json::from_slice(&read_file_limited(path, max_input_bytes)?).map_err(|_| { + Failure::Usage("verification config does not match the schema".to_string()) + })? + } + None => VerificationConfig::default_v1(), + }; + validate_verification_config(&config)?; + Ok(config) +} + +fn batch_reports( + source: &dyn GroundingSource, + citations: Vec, + config: &VerificationConfig, + config_sha256: &str, +) -> Vec { + citations + .into_iter() + .map(|citation| { + ethos_verify::verify_claims(source, citation, config, config_sha256.to_string()) + }) + .collect() +} + fn write_report( out: Option, format: VerifyOutputFormat, diff --git a/crates/ethos-cli/src/main.rs b/crates/ethos-cli/src/main.rs index ca7232b0..181cd0c1 100644 --- a/crates/ethos-cli/src/main.rs +++ b/crates/ethos-cli/src/main.rs @@ -81,6 +81,13 @@ enum Command { }, /// Citation evidence verification (ethos-verify) Verify(VerifyArgs), + /// Verify many citation requests against one loaded grounding source + VerifyBatch(VerifyBatchArgs), + /// Render a deterministic human-readable proof report + Report { + #[command(subcommand)] + command: ReportCommand, + }, /// Recompute and check a document fingerprint Fingerprint(FingerprintArgs), /// Diagnose local Ethos and caller-provided PDFium setup @@ -225,6 +232,24 @@ enum EvidenceCommand { Anchor(EvidenceAnchorArgs), } +#[derive(Subcommand)] +enum ReportCommand { + /// Render a verification report as self-contained HTML + Html(ReportHtmlArgs), +} + +#[derive(Args)] +pub(crate) struct ReportHtmlArgs { + /// Canonical verification report JSON. + pub(crate) input: PathBuf, + /// Destination HTML file. + #[arg(long)] + pub(crate) out: PathBuf, + /// Safe relative prefix used only for existing crop references. + #[arg(long)] + pub(crate) crop_root: Option, +} + #[derive(Args)] pub(crate) struct RagChunkArgs { /// Canonical document (`*.ethos.json`) @@ -291,6 +316,27 @@ pub(crate) struct VerifyArgs { pub(crate) fail_on_ungrounded: bool, } +#[derive(Args)] +pub(crate) struct VerifyBatchArgs { + /// Grounding input: canonical Ethos document, or foreign output with --grounding. + pub(crate) input: PathBuf, + /// NDJSON file containing one canonical citation input per non-empty line. + #[arg(long)] + pub(crate) citations_ndjson: PathBuf, + /// Foreign grounding adapter id (e.g. `opendataloader-json`). + #[arg(long)] + pub(crate) grounding: Option, + /// Verification config (JSON); defaults to the pinned `default-v1`. + #[arg(long)] + pub(crate) config: Option, + /// Output path for canonical verification-report NDJSON (default: stdout). + #[arg(long)] + pub(crate) out: Option, + /// Exit 1 after writing reports when any request is not fully grounded. + #[arg(long)] + pub(crate) fail_on_ungrounded: bool, +} + #[derive(Clone, Copy, ValueEnum)] pub(crate) enum VerifyOutputFormat { Json, @@ -393,6 +439,10 @@ fn run(cli: Cli) -> Result<(), Failure> { command: EvidenceCommand::Anchor(args), } => cmd::evidence::evidence_anchor(args), Command::Verify(args) => cmd::verify::verify(args), + Command::VerifyBatch(args) => cmd::verify::verify_batch(args), + Command::Report { + command: ReportCommand::Html(args), + } => cmd::report::html(args), Command::Fingerprint(args) => cmd::doc::fingerprint(args), Command::Doctor(args) => cmd::doctor::doctor(args), Command::CropElement(args) => cmd::crop::crop_element(args), diff --git a/crates/ethos-cli/tests/verify.rs b/crates/ethos-cli/tests/verify.rs index ae2b3cad..9904ba20 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -92,6 +92,16 @@ fn json_file(path: impl AsRef) -> Value { serde_json::from_slice(&bytes).expect("JSON fixture parses") } +fn citation_ndjson(citations: &[&Path]) -> String { + let mut output = citations + .iter() + .map(|path| serde_json::to_string(&json_file(path)).expect("citation fixture serializes")) + .collect::>() + .join("\n"); + output.push('\n'); + output +} + fn crop_element_request( document: &Value, element_id: &str, @@ -275,6 +285,270 @@ fn verify_alpha_demo_reports_match_goldens() { } } +#[test] +fn verify_batch_lines_byte_equal_corresponding_single_verify_reports() { + let root = repo_root(); + let document = document_example(); + let grounded = root.join("examples/verify/native_grounded_citations.json"); + let ungrounded = root.join("examples/verify/native_ungrounded_citations.json"); + let citations_ndjson = temp_json( + "verify-batch-single-report-equivalence", + &citation_ndjson(&[&grounded, &ungrounded]), + ); + + let batch = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + citations_ndjson.to_str().unwrap(), + ]); + assert_eq!(batch.status.code(), Some(0)); + assert_eq!(batch.stderr, b""); + + let expected = [grounded, ungrounded] + .iter() + .map(|citations| { + let output = run_ethos(&[ + "verify", + document.to_str().unwrap(), + "--citations", + citations.to_str().unwrap(), + ]); + assert_eq!(output.status.code(), Some(0)); + output.stdout[..output.stdout.len() - 1].to_vec() + }) + .collect::>(); + assert_eq!( + batch + .stdout + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .collect::>(), + expected.iter().map(Vec::as_slice).collect::>() + ); +} + +#[test] +fn verify_batch_preserves_request_order_and_is_byte_identical_on_repeat() { + let root = repo_root(); + let document = document_example(); + let grounded = root.join("examples/verify/native_grounded_citations.json"); + let ungrounded = root.join("examples/verify/native_ungrounded_citations.json"); + let citations_ndjson = temp_json( + "verify-batch-ordering", + &citation_ndjson(&[&ungrounded, &grounded]), + ); + let first = temp_output("verify-batch-first"); + let second = temp_output("verify-batch-second"); + + for output_path in [&first, &second] { + let output = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + citations_ndjson.to_str().unwrap(), + "--out", + output_path.to_str().unwrap(), + ]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b""); + assert_eq!(output.stderr, b""); + } + + let first_bytes = std::fs::read(&first).expect("first batch output is readable"); + assert_eq!( + first_bytes, + std::fs::read(&second).expect("second batch output is readable") + ); + let reports = first_bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice::(line).expect("NDJSON line is JSON")) + .collect::>(); + assert_eq!(reports.len(), 2); + assert_eq!(reports[0]["all_evidence_grounded"], false); + assert_eq!(reports[1]["all_evidence_grounded"], true); +} + +#[test] +fn verify_batch_fail_on_ungrounded_exits_one_after_output_and_invalid_input_writes_nothing() { + let root = repo_root(); + let document = document_example(); + let ungrounded = root.join("examples/verify/native_ungrounded_citations.json"); + let valid_ndjson = temp_json( + "verify-batch-fail-on-ungrounded", + &citation_ndjson(&[&ungrounded]), + ); + let report_output = temp_output("verify-batch-ungrounded-output"); + let ungrounded_output = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + valid_ndjson.to_str().unwrap(), + "--fail-on-ungrounded", + "--out", + report_output.to_str().unwrap(), + ]); + assert_eq!(ungrounded_output.status.code(), Some(1)); + assert_eq!(ungrounded_output.stdout, b""); + assert!(std::fs::read(&report_output) + .expect("ungrounded report is written") + .ends_with(b"\n")); + + let invalid_ndjson = temp_json( + "verify-batch-invalid-input", + &format!("{}not-json\n", citation_ndjson(&[&ungrounded])), + ); + let invalid_output = temp_output("verify-batch-invalid-output"); + let invalid = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + invalid_ndjson.to_str().unwrap(), + "--out", + invalid_output.to_str().unwrap(), + ]); + assert_eq!(invalid.status.code(), Some(2)); + assert_eq!(invalid.stdout, b""); + assert!( + !invalid_output.exists(), + "invalid batch must not create output" + ); +} + +#[test] +fn verify_batch_enforces_request_count_and_blank_line_boundaries() { + let root = repo_root(); + let document = document_example(); + let grounded = root.join("examples/verify/native_grounded_citations.json"); + let line = serde_json::to_string(&json_file(&grounded)).unwrap(); + for count in [32, 1024] { + let input = temp_json( + &format!("verify-batch-{count}"), + &(line.clone() + "\n").repeat(count), + ); + let output = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + input.to_str().unwrap(), + ]); + assert_eq!( + output.status.code(), + Some(0), + "count={count}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output + .stdout + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .count(), + count + ); + } + for (name, contents) in [ + ("empty", String::new()), + ("interior-blank", format!("{line}\n\n{line}\n")), + ("oversized", (line.clone() + "\n").repeat(1025)), + ] { + let input = temp_json(&format!("verify-batch-{name}"), &contents); + let output_path = temp_output(&format!("verify-batch-{name}-output")); + let output = run_ethos(&[ + "verify-batch", + document.to_str().unwrap(), + "--citations-ndjson", + input.to_str().unwrap(), + "--out", + output_path.to_str().unwrap(), + ]); + assert_eq!(output.status.code(), Some(2), "{name}"); + assert!(!output_path.exists(), "{name} must be atomic"); + } +} + +#[test] +fn verify_batch_supports_foreign_grounding_and_explicit_config() { + let root = repo_root(); + let cases = [ + ( + root.join("fixtures/foreign/opendataloader/real/opendataloader-output.json"), + root.join("fixtures/foreign/opendataloader/real/citations.json"), + Some("opendataloader-json"), + None, + ), + ( + document_example(), + root.join("examples/verify/native_grounded_citations.json"), + None, + Some(root.join("schemas/examples/verification-config.hardened.example.json")), + ), + ]; + for (source, citations, grounding, config) in cases { + let input = temp_json( + "verify-batch-adapter-config", + &citation_ndjson(&[&citations]), + ); + let mut batch_args = vec![ + "verify-batch", + source.to_str().unwrap(), + "--citations-ndjson", + input.to_str().unwrap(), + ]; + let mut single_args = vec![ + "verify", + source.to_str().unwrap(), + "--citations", + citations.to_str().unwrap(), + ]; + if let Some(value) = grounding { + batch_args.extend(["--grounding", value]); + single_args.extend(["--grounding", value]); + } + if let Some(path) = config.as_ref() { + batch_args.extend(["--config", path.to_str().unwrap()]); + single_args.extend(["--config", path.to_str().unwrap()]); + } + let batch = run_ethos(&batch_args); + let single = run_ethos(&single_args); + assert_eq!( + batch.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&batch.stderr) + ); + assert_eq!(batch.stdout, single.stdout); + } +} + +#[test] +fn verify_batch_rejects_crop_config_atomically() { + let root = repo_root(); + let mut config = json_file(root.join("schemas/examples/verification-config.example.json")); + config["evidence"]["include_crops"] = serde_json::json!(true); + let config_path = temp_json( + "verify-batch-crop-config", + &serde_json::to_string(&config).unwrap(), + ); + let citations = root.join("examples/verify/native_grounded_citations.json"); + let input = temp_json("verify-batch-crop-input", &citation_ndjson(&[&citations])); + let output_path = temp_output("verify-batch-crop-output"); + let output = run_ethos(&[ + "verify-batch", + document_example().to_str().unwrap(), + "--citations-ndjson", + input.to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + "--out", + output_path.to_str().unwrap(), + ]); + assert_eq!(output.status.code(), Some(2)); + assert!(!output_path.exists()); + assert!(String::from_utf8_lossy(&output.stderr).contains("does not support crop evidence")); +} + #[test] fn real_opendataloader_fixture_verifies_against_golden() { let root = repo_root(); @@ -2883,3 +3157,167 @@ fn case_insensitive_config_allows_literal_case_difference() { ); assert_eq!(report["all_evidence_grounded"], true); } + +#[test] +fn report_html_renders_supported_report_deterministically_and_escapes_content() { + let root = repo_root(); + let mut report = json_file(root.join("schemas/examples/verification-report.example.json")); + report["grounding"]["parser"]["name"] = serde_json::json!("parser