diff --git a/AGENTS.md b/AGENTS.md index e884b5d..05ba667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This is **not** part of the solstone monorepo. It is a standalone Rust package w ``` crates/solstone-linux/src/ Shipping Rust observer, CLI, service, sync, and capture code packaging/ Native package Containerfile and install notes -scripts/build-release.sh Operator-run native package build +scripts/build-release.sh Non-candidate native package drift helper scripts/install.sh Portable archive installer src/solstone_linux/ Retained former Python implementation @@ -80,6 +80,9 @@ make install # Establish pinned Rust/tools and install the observer make format # Format Rust source make test # Run locked Rust tests make check-rust-release-manifest # Validate release-manifest fixtures offline +make release-candidate # Create and locally prove one atomic candidate +make release-candidate-prove # Resume only missing package proofs +make release-candidate-recover # Read-only retained-candidate validation make ci # Host evidence: Rust format, lint, tests, offline policy make audit # Refresh RustSec data, then check advisories make update-deps # Sole unlocked Cargo dependency-update path @@ -109,19 +112,20 @@ The root Cargo workspace is workspace-only: `crates/solstone-linux/` contains th ## Releasing -solstone-linux ships as portable, Debian, and RPM artifacts through the -operator-run native release rail. There is no automated publish path. +The native rail creates portable, Debian, and RPM candidate artifacts plus three +package-bound local proofs. Publication is unavailable. ```bash -make release # build native Debian and RPM release artifacts +make release-candidate EXPECTED_RELEASE_COMMIT= ADVISORY_DESCRIPTOR= ``` -The build refuses a dirty tree and does not upload, tag, or publish. Follow -`RELEASING.md` for artifact inspection, the blocking FLAC soak, and handoff. -`scripts/release.sh` and its `legacy-python-release*` Make targets remain -functional and can publish, tag, push, and create a GitHub release when -credentials are present. They are retained for the former Python rail and are -not part of the shipping product rail. +`make release` enters the same transaction. The manifest validator, candidate +transaction, Debian/RPM/tar install proofs, and blocking live FLAC checkpoint are +distinct evidence activities. `candidate-proven` and +`retained-candidate-valid` are local evidence, not publication approval. The +individual `scripts/build-release.sh` lanes write only non-candidate drift evidence. +Follow `RELEASING.md` for image and advisory preconditions, stale-lock recovery, +proof resume, read-only recovery, and the separate FLAC checkpoint. ## Legacy Python development principles diff --git a/Makefile b/Makefile index 3329ce5..fa8953f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # solstone-linux Makefile # Standalone Linux desktop observer for solstone -.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci legacy-python-release legacy-python-release-test check-toolchain-env establish-toolchain rust-preflight check-cargo-deny +.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release release-candidate release-candidate-prove release-candidate-recover legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci legacy-python-release legacy-python-release-test check-toolchain-env establish-toolchain rust-preflight check-cargo-deny APP := solstone-linux UNIT := solstone-linux.service @@ -94,6 +94,10 @@ check-observer-contract: rust-preflight check-rust-release-manifest: rust-preflight @echo "Rust release manifest schema: 1" @echo "Rust release manifest schema SHA-256: d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c" + @echo "Rust release candidate ledger schema: 1" + @echo "Rust release candidate ledger schema SHA-256: c93e189b2e7bc1c65d38f52f924c74a101a4b3f39acbe73ba626b4f59e180533" + @echo "Rust release candidate proof schema: 1" + @echo "Rust release candidate proof schema SHA-256: 3009eab983eea832961220406f19c7459ed1db7fffc352af6ffaf664f9cd7dcf" @manifest_set=$(if $(filter environment%,$(origin MANIFEST)),1,$(if $(findstring command line,$(origin MANIFEST)),1,0)); \ release_dir_set=$(if $(filter environment%,$(origin RELEASE_DIR)),1,$(if $(findstring command line,$(origin RELEASE_DIR)),1,0)); \ if { [ "$$manifest_set" -eq 1 ] && [ -z "$(strip $(MANIFEST))" ]; } || { [ "$$release_dir_set" -eq 1 ] && [ -z "$(strip $(RELEASE_DIR))" ]; }; then echo "error: release manifest selector empty" >&2; exit 1; \ @@ -157,10 +161,21 @@ versions: rust-preflight check-cargo-deny cargo deny --version @command -v $(APP) >/dev/null 2>&1 && $(APP) --version || true -release: rust-preflight - @echo "Evidence class: target-package drift evidence. This does not run the release FLAC soak." - @bash scripts/build-release.sh deb - @bash scripts/build-release.sh rpm +release: release-candidate + +release-candidate: rust-preflight + @test -n "$(strip $(EXPECTED_RELEASE_COMMIT))" || { echo "error: expected release commit mismatch: expected EXPECTED_RELEASE_COMMIT, actual missing" >&2; echo "repair: make release-candidate EXPECTED_RELEASE_COMMIT= ADVISORY_DESCRIPTOR=" >&2; exit 1; } + @test -n "$(strip $(ADVISORY_DESCRIPTOR))" || { echo "error: advisory descriptor mismatch: expected ADVISORY_DESCRIPTOR, actual missing" >&2; echo "repair: make release-candidate EXPECTED_RELEASE_COMMIT=$(EXPECTED_RELEASE_COMMIT) ADVISORY_DESCRIPTOR=" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- candidate create --expected-release-commit "$(EXPECTED_RELEASE_COMMIT)" --advisory-descriptor "$(ADVISORY_DESCRIPTOR)" + +release-candidate-prove: rust-preflight + @test -n "$(strip $(VERSION))" || { echo "error: candidate version mismatch: expected VERSION, actual missing" >&2; echo "repair: make release-candidate-prove VERSION= ADVISORY_DESCRIPTOR=" >&2; exit 1; } + @test -n "$(strip $(ADVISORY_DESCRIPTOR))" || { echo "error: advisory descriptor mismatch: expected ADVISORY_DESCRIPTOR, actual missing" >&2; echo "repair: make release-candidate-prove VERSION=$(VERSION) ADVISORY_DESCRIPTOR=" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- candidate prove --version "$(VERSION)" --advisory-descriptor "$(ADVISORY_DESCRIPTOR)" + +release-candidate-recover: rust-preflight + @test -n "$(strip $(VERSION))" || { echo "error: candidate version mismatch: expected VERSION, actual missing" >&2; echo "repair: make release-candidate-recover VERSION=" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- candidate recover --version "$(VERSION)" clean: @echo "Cleaning build artifacts and cache files..." diff --git a/RELEASING.md b/RELEASING.md index b470aff..1ac1b4b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,205 +1,160 @@ -# Native Rust release rail - -The shipping Rust release rail is operator-run. It produces portable, Debian, -and RPM artifacts; it does not publish, tag, or create a hosted release. The -retained Python/PyPI rail is not part of the shipping product rail, though its -commands remain functional and can publish when credentials are present. - -## 1. Host prerequisites - -Run from a clean checkout with Git and either Podman or Docker. ShellCheck is -required by `make ci`. The canonical `make release` path also runs the host -Rust preflight, so it requires rustup plus the compiler and Cargo selected by -`rust-toolchain.toml`. Invoking `scripts/build-release.sh` directly requires -Git and the container engine but not host Cargo; the crate version is read -inside the build container. - -Only x86_64 is supported. The build and install scripts refuse every other -architecture rather than placing an x86_64 binary under a misleading name. - -The compiler authority is `rust-toolchain.toml` (`1.97.1`). Native package -tools are pinned to cargo-deb `3.7.0` and cargo-generate-rpm `0.21.0`; their -exact single-line version banners are asserted in the build container before -packaging starts. - -## 2. Version source and output names - -The Rust version comes from `[workspace.package].version` and the member's -`version.workspace = true`. It is independent of the Python package version. -Every artifact is written below `dist/rust/` and contains the Rust version: - -- `solstone-linux--linux-x86_64.tar.gz` -- `solstone-linux_-1_amd64.deb` -- `solstone-linux--1.x86_64.rpm` - -## 3. Build commands - -The canonical command builds both native package families after its host -toolchain preflight: - -```bash -make release -``` - -To invoke either container build directly without the host preflight, build -each package family explicitly: - -```bash -scripts/build-release.sh deb -scripts/build-release.sh rpm +# Native Rust release candidate rail + +The native rail creates local release-candidate evidence. It does not tag, sign, +upload, publish, or approve publication. Publication is unavailable in this rail. +Only x86_64 is supported. + +Four evidence activities remain deliberately separate: + +1. The offline release-manifest validator checks a named manifest or an exact + five-file payload directory. +2. The candidate transaction builds tar, Debian, and RPM artifacts from one + committed immutable context and atomically promotes the five-file payload. +3. Three package-bound proofs install and verify the Debian, RPM, and tar + artifacts in separate network-disabled environments. +4. The live FLAC checkpoint exercises the observer on a desktop after candidate + proof. It is not part of `candidate-proven`. + +## Operator preconditions + +Use a clean checkout at the exact release commit. The compiler authority is +`rust-toolchain.toml`; Cargo operations use `Cargo.lock`. + +Provision all five images locally before transaction entry: Ubuntu and Fedora +build-tool images plus the Debian, RPM, and tar proof images. Record their exact +immutable digest references in `packaging/release-policy.toml`. For each proof +image, observe and commit the exact normalized OS release, package-manager output, +install argv, version argv, executable path, and executable mode. Commit those +values before selecting `EXPECTED_RELEASE_COMMIT`. The proof producer observes the +same values inside the selected image and fails closed when committed policy is +wrong. The transaction never pulls an image. + +Acquire the advisory database outside the transaction. It must be a clean local Git +worktree, including no untracked or ignored changes. Write a strict descriptor with +exactly these private operator inputs: + +```json +{ + "schema_version": 1, + "source_id": "privacy-safe cohort identifier", + "db_path": "/absolute/canonical/path/to/local/advisory-db", + "acquired_at": "2026-07-21T00:00:00Z" +} ``` -Both commands also produce the same Ubuntu 22.04 baseline tarball. The whole -repository is the container build context because the Rust build script reads -and rasterizes `contrib/icons/`. +The descriptor and database paths never enter public candidate evidence. Candidate +creation requires acquisition within 24 hours. Proof resume validates the retained +database identity without reapplying that freshness window. -The Debian package is built inside Ubuntu. In particular, cargo-deb's -`depends = "$auto"` must run where `dpkg-shlibdeps` exists; an openSUSE host -does not provide it. The RPM stage runs cargo-generate-rpm's automatic -requirements scan in its native packaging environment but packages the binary -copied from the Ubuntu stage, preserving the glibc 2.35 floor. +## Candidate commands -## 4. Inspect artifacts - -Before distributing anything, inspect metadata and contents: +Create the complete candidate and all three proofs: ```bash -tar -tzf dist/rust/solstone-linux-*-linux-x86_64.tar.gz -dpkg-deb -I dist/rust/solstone-linux_*-1_amd64.deb -dpkg-deb -c dist/rust/solstone-linux_*-1_amd64.deb -rpm -qpi dist/rust/solstone-linux-*-1.x86_64.rpm -rpm -qpl dist/rust/solstone-linux-*-1.x86_64.rpm -sha256sum dist/rust/* +make release-candidate \ + EXPECTED_RELEASE_COMMIT= \ + ADVISORY_DESCRIPTOR= ``` -Confirm that each artifact contains the binary, LICENSE, INSTALL-NOTES, and the -icon set declared in the member package manifest. It must contain no systemd -unit and no desktop file. - -### Render and validate the release manifest +`make release` delegates to this same target and requires the same variables. There +is no second native release state machine. -After all three product artifacts exist, provide explicit release evidence to -the repository-local renderer. It writes -`solstone-linux--linux-x86_64.rust-release-manifest.json` and -`SHA256SUMS` without discovering tool versions or contacting a service. Both -files list the tarball, Debian package, and RPM in POSIX-basename order. -Checksum rows use lowercase SHA-256, two spaces, the basename, and LF; neither -companion file lists itself or the other. - -Run the inert offline fixture gate with no payload selector: +Resume missing proofs without rebuilding or changing valid retained evidence: ```bash -make check-rust-release-manifest +make release-candidate-prove \ + VERSION= \ + ADVISORY_DESCRIPTOR= ``` -Verify one named manifest and its exact artifact bytes with: +Read-only recovery validation requires no advisory descriptor: ```bash -make check-rust-release-manifest MANIFEST=dist/rust/solstone-linux-1.0.0-linux-x86_64.rust-release-manifest.json +make release-candidate-recover VERSION= ``` -This mode is not candidate-readiness classification because it does not reject -unrelated directory entries. Classify a complete candidate with: +All mutating candidate commands use `dist/.rust-release-candidate.lock`. Commands do +not auto-clear a stale lock. After confirming no candidate process is running, the +operator may remove that exact lock file and retry. Do not remove any broader +directory as lock recovery. -```bash -make check-rust-release-manifest RELEASE_DIR=dist/rust -``` +## Outputs and meanings -Candidate classification requires exactly five regular, non-symlink files: -the tarball, Debian package, RPM, `SHA256SUMS`, and versioned manifest. Release -validation requires the repository to be clean except for the supplied, -git-ignored payload under `dist/`. Active advisory exceptions retain their -exact `deny.toml` file order. The renderer and validator are offline and -provider-neutral; signing and publication remain separate operator actions. +The promoted payload is exactly: -## 5. Blocking first-release FLAC validation +- `dist/rust/solstone-linux--linux-x86_64.tar.gz` +- `dist/rust/solstone-linux_-1_amd64.deb` +- `dist/rust/solstone-linux--1.x86_64.rpm` +- `dist/rust/SHA256SUMS` +- `dist/rust/solstone-linux--linux-x86_64.rust-release-manifest.json` -This checkpoint is mandatory. Do not release based only on a successful link. +Retained evidence is versioned and disjoint from the payload: -The release binary statically links the bundled libFLAC, so it has no direct -cross-distribution libFLAC runtime dependency. A distro's PulseAudio stack may -independently load its own libFLAC through libsndfile. The pre-release soak must -still exercise real encoded output through the shipped binary: +- `dist/rust-evidence//ledger.json` +- `dist/rust-evidence//proofs/debian-amd64.json` +- `dist/rust-evidence//proofs/rpm-x86_64.json` +- `dist/rust-evidence//proofs/tar-x86_64.json` -1. Install the produced artifact on a test Linux desktop with the runtime - dependencies from `packaging/INSTALL-NOTES`. -2. Run the packaged `solstone-linux` observer long enough to produce a new - audio segment. -3. Confirm the observer remains alive and validate that exact segment with - `flac -t path/to/new/audio.flac` (or each split mono FLAC). -4. Treat any encoder crash or decode failure as a release blocker. +`candidate-proven` means all local payload, ledger, policy, image, source, and three +package-install/version proofs validated together. `retained-candidate-valid` means +the retained candidate validates read-only in the matching clean checkout. Both are +local evidence statuses. Neither is publication approval, and neither includes the +live FLAC checkpoint. -If the checkpoint fails, stop and diagnose the bundled encoder before release. +The Debian and RPM proofs install only their bound local package. The tar proof runs +both installer dry-run and isolated-prefix installation. All three validate the +installed executable path, mode, hash, and exact version output with networking +disabled. -## 6. Portable installer +## Validator -Preview a local tarball installation without writes: +Run the offline fixture and schema gate: ```bash -scripts/install.sh --dry-run "dist/rust/solstone-linux--linux-x86_64.tar.gz" +make check-rust-release-manifest ``` -Install to the default `$HOME/.local` prefix: +Validate a named manifest or classify an exact payload: ```bash -scripts/install.sh "dist/rust/solstone-linux--linux-x86_64.tar.gz" +make check-rust-release-manifest MANIFEST=dist/rust/solstone-linux--linux-x86_64.rust-release-manifest.json +make check-rust-release-manifest RELEASE_DIR=dist/rust ``` -The script reports when `$HOME/.local/bin` is not on PATH. A different prefix -requires explicit `--prefix PATH`; the script never silently invokes sudo. -Unknown distribution families stop without making changes. - -Run `solstone-linux install-service` after installing the binary. The native -command writes the user unit and desktop autostart entry, reloads systemd, and -enables and starts the observer service. - -## 7. Runtime dependencies - -The canonical cross-distribution list is committed once in -`packaging/INSTALL-NOTES` and is included in every artifact. Verify it against -the intended test machine before the soak. +Named-manifest validation does not imply candidate readiness. Directory +classification requires exactly five regular files and rejects stale or extra +entries. -## 8. Manual release handoff +## Non-candidate drift helper -After both builds, artifact inspection, checksums, and the blocking FLAC soak -succeed, upload the three versioned files and checksum list through the chosen -manual release surface. Do not reuse the Python version, Python tag/publish -script, or PyPI release artifacts. +The individual lane helper is deliberately outside candidate state: -Release-note bodies come only from the matching `CHANGELOG.md` block. Extract -that block with `scripts/extract_changelog.sh `; do not create a -separate engineering-tone release-note template here. - -## 9. Known constraints - -- x86_64 only -- glibc 2.35 baseline -- no packaged unit file or desktop file -- native service files are installed at runtime rather than packaged -- release panics unwind; reconsider abort only at the Rust cutover +```bash +bash scripts/build-release.sh deb +bash scripts/build-release.sh rpm +``` -## 10. Failure recovery +It writes only `dist/rust-drift/`, labels its output as drift evidence, and cannot +create or replace the candidate payload or readiness evidence. Drift output is not +retained candidate evidence. -Container builds and local installs do not publish, tag, or push. Fix the -reported problem, remove only the affected files under `dist/rust/`, and rerun -the relevant `deb` or `rpm` command. Never relabel an artifact built for a -different architecture or version. +## Blocking live FLAC checkpoint -## 11. Evidence classes +After `candidate-proven`, install the relevant candidate artifact on a test Linux +desktop with the runtime dependencies in `packaging/INSTALL-NOTES`. Run the packaged +observer long enough to produce a new audio segment, confirm the observer remains +alive, and validate that exact segment with `flac -t` (including each split mono +FLAC when applicable). An encoder crash or decode failure blocks release handling. -| Evidence class | What it proves | What it does not prove | -|---|---|---| -| Host evidence | Source formatting, lint, tests, and offline dependency policy | Target-distribution packaging or runtime behavior | -| Target-package drift evidence | Container compiler/tool pins and distro-native package construction | Installed-artifact behavior or the release soak | -| Shipped-artifact proof | Artifact contents, linkage, installation, and the manual FLAC soak | Behavior outside the tested artifact and environment | +This live checkpoint is separate operator evidence. It does not modify the ledger, +proofs, bundle digest, or candidate status. -`make ci` names itself as host evidence. Container package gates name the -target-package class. Neither may claim the blocking FLAC soak ran; only the -operator completing section 5 has shipped-artifact proof. +## Host and advisory gates -## 12. Dependency policy +`make ci` produces host evidence: formatting, lint, tests, shell checks, and the +offline licenses/bans/sources policy. It does not run target package proofs or the +live FLAC checkpoint. -`make ci` runs cargo-deny offline for licenses, bans, and sources. It does not -fetch or inspect advisories. `make audit` first refreshes the RustSec database -and stops nonzero if refresh fails, then performs the locked advisory check. -This prevents stale cached data from being presented as freshly audited. +`make audit` refreshes advisory data for a separate operator audit. Candidate +creation instead consumes the explicitly acquired descriptor cohort and leaves the +repository `deny.toml` unchanged. diff --git a/crates/rust-release-manifest/src/candidate.rs b/crates/rust-release-manifest/src/candidate.rs new file mode 100644 index 0000000..2a992cc --- /dev/null +++ b/crates/rust-release-manifest/src/candidate.rs @@ -0,0 +1,1442 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use chrono::{DateTime, Utc}; +use serde::de::DeserializeOwned; +use std::ffi::{OsStr, OsString}; +use std::process::Output; + +const ADVISORY_URL: &str = "file://localhost/advisory-db"; +const DAY_SECONDS: i64 = 24 * 60 * 60; +pub(crate) const LANE_EVIDENCE_NAME: &str = "lane-evidence.json"; +pub const LANE_HANDOFF: &str = ".lane-evidence-handoff.json"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseImages { + pub build_ubuntu: String, + pub build_fedora: String, + pub proof_debian: String, + pub proof_rpm: String, + pub proof_tar: String, + #[serde(rename = "debian-amd64")] + pub debian_amd64: ProofPlatformPolicy, + #[serde(rename = "rpm-x86_64")] + pub rpm_x86_64: ProofPlatformPolicy, + #[serde(rename = "tar-x86_64")] + pub tar_x86_64: ProofPlatformPolicy, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ProofPlatformPolicy { + pub image_digest: String, + pub os_release: String, + pub package_manager_version: String, + pub install_command: Vec, + pub version_command: Vec, + pub executable_path: String, + pub executable_mode: u64, +} + +impl ReleaseImages { + pub fn from_context(context: &ImmutableContext) -> Result { + Self::from_root(&context.path) + } + + pub fn from_root(root: &Path) -> Result { + let path = root.join("packaging/release-policy.toml"); + require_regular(&path, "release policy authority")?; + let images: Self = toml::from_str(&fs::read_to_string(path).map_err(display_error)?) + .map_err(|_| { + Error::new( + "release policy mismatch: expected exact image and proof policy, actual invalid\nrepair: restore packaging/release-policy.toml from the release commit", + ) + })?; + for (role, value) in images.roles() { + validate_image_reference(role, value)?; + } + for (id, policy, image) in images.proof_policies() { + require_image_digest(&policy.image_digest)?; + let expected = format!("sha256:{}", image.rsplit_once("sha256:").unwrap().1); + if policy.image_digest != expected + || policy.install_command.is_empty() + || policy.version_command.is_empty() + || policy.executable_mode > 0o7777 + { + return Err(Error::new(format!( + "release proof policy {id} mismatch: expected image-bound exact values, actual invalid\nrepair: commit values observed from the provisioned proof image" + ))); + } + for value in std::iter::once(policy.os_release.as_str()) + .chain(std::iter::once(policy.package_manager_version.as_str())) + .chain(policy.install_command.iter().map(String::as_str)) + .chain(policy.version_command.iter().map(String::as_str)) + .chain(std::iter::once(policy.executable_path.as_str())) + { + if value.contains('/') { + if value.chars().any(char::is_control) + || !(value.starts_with("/input/") + || value.starts_with("/proof-root") + || value.starts_with("--root=/proof-root") + || value.starts_with("/usr/bin/") + || value.starts_with("/bin/")) + { + return Err(Error::new( + "release proof policy path mismatch: expected stable in-container path, actual invalid", + )); + } + } else { + validate_evidence_text("release proof policy", value)?; + } + } + } + Ok(images) + } + + pub fn proof_policies(&self) -> [(&'static str, &ProofPlatformPolicy, &str); 3] { + [ + ("debian-amd64", &self.debian_amd64, &self.proof_debian), + ("rpm-x86_64", &self.rpm_x86_64, &self.proof_rpm), + ("tar-x86_64", &self.tar_x86_64, &self.proof_tar), + ] + } + + pub fn proof_policy(&self, id: &str) -> Result<&ProofPlatformPolicy> { + self.proof_policies() + .into_iter() + .find_map(|(actual, policy, _)| (actual == id).then_some(policy)) + .ok_or_else(|| Error::new("proof platform policy mismatch")) + } + + pub fn roles(&self) -> [(&'static str, &str); 5] { + [ + ("build_ubuntu", &self.build_ubuntu), + ("build_fedora", &self.build_fedora), + ("proof_debian", &self.proof_debian), + ("proof_rpm", &self.proof_rpm), + ("proof_tar", &self.proof_tar), + ] + } +} + +fn validate_image_reference(role: &str, value: &str) -> Result<()> { + let digest = value + .strip_prefix("sha256:") + .or_else(|| value.rsplit_once("@sha256:").map(|(_, digest)| digest)); + let named = value.rsplit_once("@sha256:").map(|(name, _)| name); + if digest.is_none_or(|digest| !is_sha256(digest)) + || named.is_some_and(|name| { + name.is_empty() + || name.starts_with('-') + || name.chars().any(|character| { + !(character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-' | ':' | '/')) + }) + }) + { + return Err(Error::new(format!( + "release image {role} mismatch: expected immutable digest reference, actual invalid\nrepair: commit the locally provisioned image digest for {role} in packaging/release-policy.toml" + ))); + } + if let Some(name) = named { + validate_evidence_text(&format!("release image {role}"), name).map_err(|_| { + Error::new(format!("release image {role} mismatch: expected privacy-safe immutable reference, actual invalid\nrepair: commit a privacy-safe digest reference in packaging/release-policy.toml")) + })?; + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct ResolvedImages { + pub build_ubuntu: ImageIdentity, + pub build_fedora: ImageIdentity, + pub proof_debian: ImageIdentity, + pub proof_rpm: ImageIdentity, + pub proof_tar: ImageIdentity, +} + +pub fn resolve_release_images( + processes: &ProcessEnvironment, + engine: ContainerEngine, + images: &ReleaseImages, +) -> Result { + let resolve = |role: &str, reference: &str| { + let identity = inspect_image(processes, engine, reference).map_err(|_| { + Error::new(format!( + "release image {role} mismatch: expected provisioned local image, actual unavailable\nrepair: provision {reference} locally before candidate entry" + )) + })?; + let expected = reference.rsplit_once("sha256:").unwrap().1; + if identity.digest != format!("sha256:{expected}") { + return Err(Error::new(format!( + "release image {role} mismatch: expected sha256:{expected}, actual {}\nrepair: provision the committed digest locally before candidate entry", + identity.digest + ))); + } + Ok(identity) + }; + Ok(ResolvedImages { + build_ubuntu: resolve("build_ubuntu", &images.build_ubuntu)?, + build_fedora: resolve("build_fedora", &images.build_fedora)?, + proof_debian: resolve("proof_debian", &images.proof_debian)?, + proof_rpm: resolve("proof_rpm", &images.proof_rpm)?, + proof_tar: resolve("proof_tar", &images.proof_tar)?, + }) +} + +#[derive(Clone, Debug, Default)] +pub struct ProcessEnvironment { + path: Option, + git_canaries: Option<(OsString, OsString)>, +} + +impl ProcessEnvironment { + pub fn with_path(path: &OsStr) -> Self { + Self { + path: Some(path.to_owned()), + git_canaries: None, + } + } + + pub fn with_git_canaries(mut self, git_dir: &OsStr, git_work_tree: &OsStr) -> Self { + self.git_canaries = Some((git_dir.to_owned(), git_work_tree.to_owned())); + self + } + + fn command(&self, program: &str) -> Command { + let mut command = Command::new(program); + if let Some(path) = &self.path { + command.env("PATH", path); + } + if let Some((git_dir, git_work_tree)) = &self.git_canaries { + command + .env("GIT_DIR", git_dir) + .env("GIT_WORK_TREE", git_work_tree); + } + // Candidate Git operations must always discover the worktree selected by + // current_dir, never an ambient caller override. + command.env_remove("GIT_DIR").env_remove("GIT_WORK_TREE"); + command + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdvisoryDescriptor { + pub schema_version: u64, + pub source_id: String, + pub db_path: PathBuf, + pub acquired_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdvisoryIdentity { + pub source_id: String, + pub commit: String, + pub archive_sha256: String, + pub acquired_at: String, + snapshot_path: PathBuf, +} + +pub fn validate_advisory_descriptor_identity( + descriptor_path: &Path, + processes: &ProcessEnvironment, +) -> Result { + validate_advisory_descriptor_identity_mode(descriptor_path, processes, true) +} + +pub fn validate_resume_advisory_identity( + descriptor_path: &Path, + processes: &ProcessEnvironment, +) -> Result { + validate_advisory_descriptor_identity_mode(descriptor_path, processes, false) +} + +fn validate_advisory_descriptor_identity_mode( + descriptor_path: &Path, + processes: &ProcessEnvironment, + enforce_freshness: bool, +) -> Result { + require_regular(descriptor_path, "advisory descriptor")?; + let descriptor: AdvisoryDescriptor = strict_json_file(descriptor_path).map_err(|_| { + Error::new( + "advisory descriptor mismatch: expected strict four-field JSON, actual invalid\nrepair: provide the retained cohort descriptor", + ) + })?; + if descriptor.schema_version != 1 { + return Err(Error::new(format!( + "advisory descriptor schema mismatch: expected 1, actual {}\nrepair: provide a schema-version 1 descriptor", + descriptor.schema_version + ))); + } + validate_evidence_text("advisory source_id", &descriptor.source_id)?; + validate_timestamp(&descriptor.acquired_at)?; + let acquired = DateTime::parse_from_rfc3339(&descriptor.acquired_at).map_err(display_error)?; + let age = Utc::now().signed_duration_since(acquired); + if enforce_freshness && (age.num_seconds() < 0 || age.num_seconds() > DAY_SECONDS) { + return Err(Error::new( + "advisory acquisition time mismatch: expected within 24 hours, actual stale\nrepair: acquire and commit a current advisory cohort before finalization", + )); + } + if !descriptor.db_path.is_absolute() + || descriptor.db_path.as_os_str().as_encoded_bytes().first() == Some(&b'-') + { + return Err(Error::new( + "advisory database path mismatch: expected absolute non-option path, actual invalid\nrepair: provide an absolute local advisory database path", + )); + } + let metadata = fs::symlink_metadata(&descriptor.db_path).map_err(|_| { + Error::new( + "advisory database mismatch: expected present no-follow directory, actual unavailable\nrepair: provision the retained advisory database locally", + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::new( + "advisory database mismatch: expected no-follow directory, actual other\nrepair: provide a canonical regular directory", + )); + } + let snapshot_path = descriptor.db_path.canonicalize().map_err(|_| { + Error::new("advisory database mismatch: expected canonical directory, actual invalid") + })?; + if snapshot_path != descriptor.db_path { + return Err(Error::new( + "advisory database path mismatch: expected canonical path, actual noncanonical\nrepair: use the canonical absolute database path", + )); + } + if run_stdout( + processes, + &snapshot_path, + "git", + &["rev-parse", "--is-inside-work-tree"], + )? != "true" + { + return Err(Error::new( + "advisory database mismatch: expected git worktree, actual other", + )); + } + let status = run_stdout( + processes, + &snapshot_path, + "git", + &[ + "status", + "--porcelain", + "--untracked-files=all", + "--ignored=matching", + ], + )?; + if !status.is_empty() { + return Err(Error::new( + "advisory database status mismatch: expected clean, actual dirty\nrepair: restore the retained advisory database to its committed state", + )); + } + let commit = run_stdout(processes, &snapshot_path, "git", &["rev-parse", "HEAD"])?; + require_commit(&commit, "advisory database commit")?; + let archive = run_output( + processes, + &snapshot_path, + "git", + &["archive", "--format=tar", "HEAD"], + )? + .stdout; + Ok(AdvisoryIdentity { + source_id: descriptor.source_id, + commit, + archive_sha256: digest(&archive), + acquired_at: descriptor.acquired_at, + snapshot_path, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdvisoryCohort { + pub source_id: String, + pub commit: String, + pub archive_sha256: String, + pub acquired_at: String, + pub cargo_deny_version: String, + pub deterministic_gate: String, + pub licenses_bans_sources: String, + pub advisories: String, + pub checked_at: String, + snapshot_path: PathBuf, +} + +pub fn run_advisory_cohort( + context: &ImmutableContext, + staging: &StagingLayout, + descriptor_path: &Path, + processes: &ProcessEnvironment, +) -> Result { + run_advisory_cohort_mode(context, staging, descriptor_path, processes) +} + +fn run_advisory_cohort_mode( + context: &ImmutableContext, + staging: &StagingLayout, + descriptor_path: &Path, + processes: &ProcessEnvironment, +) -> Result { + let identity = validate_advisory_descriptor_identity(descriptor_path, processes)?; + let snapshot_path = identity.snapshot_path; + let commit = identity.commit; + let archive_sha256 = identity.archive_sha256; + + let db_root = &staging.advisory_db; + require_directory(db_root, "isolated advisory database root")?; + let derived = db_root.join(advisory_db_directory(ADVISORY_URL)?); + if derived.exists() { + return Err(Error::new( + "isolated advisory database mismatch: expected absent, actual present", + )); + } + let source = snapshot_path + .to_str() + .ok_or_else(|| Error::new("advisory database path mismatch: expected UTF-8"))?; + let destination = derived + .to_str() + .ok_or_else(|| Error::new("advisory database path mismatch: expected UTF-8"))?; + run_success( + processes, + &context.path, + "git", + &["clone", "--no-hardlinks", source, destination], + )?; + if run_stdout(processes, &derived, "git", &["rev-parse", "HEAD"])? != commit + || !run_stdout( + processes, + &derived, + "git", + &[ + "status", + "--porcelain", + "--untracked-files=all", + "--ignored=matching", + ], + )? + .is_empty() + || digest( + &run_output( + processes, + &derived, + "git", + &["archive", "--format=tar", "HEAD"], + )? + .stdout, + ) != archive_sha256 + { + return Err(Error::new( + "materialized advisory database mismatch: expected validated source, actual different", + )); + } + + let config_path = staging.root.join("advisory-deny.toml"); + let deny = fs::read_to_string(context.path.join("deny.toml")).map_err(display_error)?; + let config = advisory_config(&deny, db_root)?; + fs::write(&config_path, config).map_err(display_error)?; + + run_cargo_deny( + processes, + &context.path, + &context.path.join("deny.toml"), + &["licenses", "bans", "sources"], + )?; + run_cargo_deny(processes, &context.path, &config_path, &["advisories"])?; + let checked_at = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + Ok(AdvisoryCohort { + source_id: identity.source_id, + commit, + archive_sha256, + acquired_at: identity.acquired_at, + cargo_deny_version: CARGO_DENY_VERSION.into(), + deterministic_gate: "pass".into(), + licenses_bans_sources: "pass".into(), + advisories: "pass".into(), + checked_at, + snapshot_path, + }) +} + +pub fn recheck_advisory_cohort( + cohort: &AdvisoryCohort, + processes: &ProcessEnvironment, + validation_time: DateTime, +) -> Result<()> { + let acquired = DateTime::parse_from_rfc3339(&cohort.acquired_at).map_err(display_error)?; + let age = validation_time.signed_duration_since(acquired); + if age.num_seconds() < 0 || age.num_seconds() > DAY_SECONDS { + return Err(Error::new( + "advisory acquisition time mismatch: expected within 24 hours, actual stale", + )); + } + let commit = run_stdout( + processes, + &cohort.snapshot_path, + "git", + &["rev-parse", "HEAD"], + )?; + let status = run_stdout( + processes, + &cohort.snapshot_path, + "git", + &[ + "status", + "--porcelain", + "--untracked-files=all", + "--ignored=matching", + ], + )?; + let archive = run_output( + processes, + &cohort.snapshot_path, + "git", + &["archive", "--format=tar", "HEAD"], + )?; + if commit != cohort.commit + || !status.is_empty() + || digest(&archive.stdout) != cohort.archive_sha256 + { + return Err(Error::new( + "advisory snapshot mismatch: expected finalized cohort, actual changed", + )); + } + Ok(()) +} + +fn advisory_config(source: &str, db_root: &Path) -> Result { + let marker = "[advisories]\n"; + let index = source.find(marker).ok_or_else(|| { + Error::new("advisory policy mismatch: expected [advisories], actual missing") + })? + marker.len(); + let root = db_root + .to_str() + .ok_or_else(|| Error::new("advisory db-path mismatch: expected UTF-8"))?; + if root.contains(['"', '\n', '\r']) { + return Err(Error::new("advisory db-path mismatch: expected safe path")); + } + let additions = format!( + "db-path = \"{root}\"\ndb-urls = [\"{ADVISORY_URL}\"]\nmaximum-db-staleness = \"1d\"\n" + ); + Ok(format!( + "{}{}{}", + &source[..index], + additions, + &source[index..] + )) +} + +fn run_cargo_deny( + processes: &ProcessEnvironment, + root: &Path, + config: &Path, + checks: &[&str], +) -> Result<()> { + let config = config + .to_str() + .ok_or_else(|| Error::new("cargo-deny config mismatch: expected UTF-8"))?; + let mut args = vec!["deny", "--locked", "--offline", "--config", config, "check"]; + args.extend_from_slice(checks); + run_success(processes, root, "cargo", &args) +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ContainerEngine { + Podman, + Docker, +} + +impl ContainerEngine { + pub(crate) fn executable(self) -> &'static str { + match self { + Self::Podman => "podman", + Self::Docker => "docker", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImageIdentity { + pub configured_tag: String, + pub digest: String, +} + +pub fn inspect_image( + processes: &ProcessEnvironment, + engine: ContainerEngine, + tag: &str, +) -> Result { + if tag.is_empty() || tag.chars().any(char::is_control) { + return Err(Error::new( + "image tag mismatch: expected configured tag, actual invalid", + )); + } + if engine == ContainerEngine::Docker { + run_success(processes, Path::new("."), "docker", &["buildx", "version"])?; + } + let output = run_output( + processes, + Path::new("."), + engine.executable(), + &["image", "inspect", tag], + )?; + let values: Vec = serde_json::from_slice(&output.stdout).map_err(display_error)?; + if values.len() != 1 { + return Err(Error::new(format!( + "image inspect result mismatch: expected 1, actual {}", + values.len() + ))); + } + let image = &values[0]; + let id = image["Id"] + .as_str() + .ok_or_else(|| Error::new("image ID mismatch: expected 64 lowercase hex, actual missing"))? + .strip_prefix("sha256:") + .unwrap_or_else(|| image["Id"].as_str().unwrap()); + if !is_sha256(id) { + return Err(Error::new( + "image ID mismatch: expected 64 lowercase hex, actual invalid", + )); + } + if image["Os"] != "linux" || image["Architecture"] != "amd64" { + return Err(Error::new(format!( + "image platform mismatch: expected linux/amd64, actual {}/{}", + image["Os"].as_str().unwrap_or("missing"), + image["Architecture"].as_str().unwrap_or("missing") + ))); + } + Ok(ImageIdentity { + configured_tag: tag.into(), + digest: format!("sha256:{id}"), + }) +} + +pub fn observe_container_engine( + processes: &ProcessEnvironment, + engine: ContainerEngine, +) -> Result { + let raw = run_stdout( + processes, + Path::new("."), + engine.executable(), + &["--version"], + )?; + let identity = match engine { + ContainerEngine::Podman => raw, + ContainerEngine::Docker => { + let version = raw + .strip_prefix("Docker version ") + .and_then(|tail| tail.split(',').next()) + .ok_or_else(|| Error::new("container engine mismatch: expected Docker version"))?; + format!("docker {version}") + } + }; + validate_identity("container_engine", &identity)?; + Ok(identity) +} + +pub fn detect_container_engine(processes: &ProcessEnvironment) -> Result { + if run_stdout(processes, Path::new("."), "podman", &["--version"]).is_ok() { + return Ok(ContainerEngine::Podman); + } + if run_stdout(processes, Path::new("."), "docker", &["--version"]).is_ok() { + return Ok(ContainerEngine::Docker); + } + Err(Error::new( + "container engine mismatch: expected local podman or docker, actual unavailable\nrepair: provision a supported local container engine before candidate entry", + )) +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Lane { + Deb, + Rpm, +} + +impl Lane { + fn target(self) -> &'static str { + match self { + Self::Deb => "deb", + Self::Rpm => "rpm", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct LaneEvidence { + pub invocation_id: String, + pub lane: Lane, + pub source_commit: String, + pub source_archive_sha256: String, + pub cargo_lock_sha256: String, + pub version: String, + pub target: String, + pub profile: String, + pub features: Vec, + pub rustc_verbose: String, + pub cargo: String, + pub baseline_executable_sha256: String, + pub image_digest: String, + pub packaging_tool: String, + pub native_tools: LaneNativeTools, + pub artifacts: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum LaneNativeTools { + Ubuntu(UbuntuLaneTools), + Fedora(FedoraLaneTools), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct UbuntuLaneTools { + pub cargo_deb: String, + pub dpkg_deb: String, + pub signing_mode: String, + pub ubuntu_cargo: String, + pub ubuntu_compiler: String, + pub ubuntu_glibc: String, + pub ubuntu_gzip: String, + pub ubuntu_image_digest: String, + pub ubuntu_linker: String, + pub ubuntu_os: String, + pub ubuntu_rustc: String, + pub ubuntu_tar: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FedoraLaneTools { + pub cargo_generate_rpm: String, + pub fedora_image_digest: String, + pub fedora_os: String, + pub rpm: String, + pub signing_mode: String, +} + +pub struct LaneRequest<'a> { + pub repo: &'a RepoRoot, + pub context: &'a ImmutableContext, + pub lane: Lane, + pub engine: ContainerEngine, + pub invocation_id: &'a str, + pub version: &'a str, + pub ubuntu: &'a ImageIdentity, + pub fedora: &'a ImageIdentity, + pub output: &'a Path, + pub processes: &'a ProcessEnvironment, +} + +pub struct LaneEmitRequest<'a> { + pub lane: Lane, + pub invocation_id: &'a str, + pub source_commit: &'a str, + pub source_archive_sha256: &'a str, + pub expected_cargo_lock_sha256: &'a str, + pub version: &'a str, + pub target: &'a str, + pub profile: &'a str, + pub features: Vec, + pub image_digest: &'a str, + pub baseline_executable: &'a Path, + pub artifacts: Vec, + pub output: &'a Path, +} + +pub fn emit_lane_handoff(request: &LaneEmitRequest<'_>) -> Result<()> { + require_image_digest(request.image_digest)?; + require_commit(request.source_commit, "lane source commit")?; + for (label, value) in [ + ("source archive digest", request.source_archive_sha256), + ("Cargo.lock digest", request.expected_cargo_lock_sha256), + ] { + if !is_sha256(value) { + return Err(Error::new(format!( + "lane {label} mismatch: expected 64 lowercase hex, actual {value}" + ))); + } + } + validate_version(request.version)?; + let actual_lock = digest(&fs::read("Cargo.lock").map_err(display_error)?); + if actual_lock != request.expected_cargo_lock_sha256 { + return Err(Error::new(format!( + "Cargo.lock digest mismatch: expected {}, actual {actual_lock}", + request.expected_cargo_lock_sha256 + ))); + } + let mut features = request.features.clone(); + features.sort(); + if features != request.features { + return Err(Error::new( + "lane features mismatch: expected sorted features, actual unsorted", + )); + } + let rustc_verbose = command_evidence("rustc", &["--version", "--verbose"])?; + let cargo = command_evidence("cargo", &["--version"])?; + let packaging_tool = match request.lane { + Lane::Deb => command_evidence("cargo", &["deb", "--version"]), + Lane::Rpm => command_evidence("cargo-generate-rpm", &["--version"]), + }?; + let image_id = request.image_digest.strip_prefix("sha256:").unwrap(); + let native_package = request + .artifacts + .iter() + .find(|path| { + path.extension().and_then(OsStr::to_str) + == Some(match request.lane { + Lane::Deb => "deb", + Lane::Rpm => "rpm", + }) + }) + .ok_or_else(|| { + Error::new("lane native artifact mismatch: expected package, actual missing") + })?; + let signing_mode = observe_signing_mode(request.lane, native_package)?; + let native_tools = match request.lane { + Lane::Deb => LaneNativeTools::Ubuntu(UbuntuLaneTools { + cargo_deb: version_token(&packaging_tool, "cargo-deb")?, + dpkg_deb: dpkg_deb_identity()?, + signing_mode, + ubuntu_cargo: two_word_identity(&cargo, "cargo")?, + ubuntu_compiler: command_first_line("cc", &["--version"])?, + ubuntu_glibc: command_evidence("getconf", &["GNU_LIBC_VERSION"])?, + ubuntu_gzip: command_first_line("gzip", &["--version"])?, + ubuntu_image_digest: image_id.into(), + ubuntu_linker: command_first_line("ld", &["--version"])?, + ubuntu_os: os_pretty_name()?, + ubuntu_rustc: two_word_identity(&rustc_verbose, "rustc")?, + ubuntu_tar: command_first_line("tar", &["--version"])?, + }), + Lane::Rpm => LaneNativeTools::Fedora(FedoraLaneTools { + cargo_generate_rpm: version_token(&packaging_tool, "cargo-generate-rpm")?, + fedora_image_digest: image_id.into(), + fedora_os: os_pretty_name()?, + rpm: command_evidence("rpm", &["--version"])?, + signing_mode, + }), + }; + let evidence = LaneEvidence { + invocation_id: request.invocation_id.into(), + lane: request.lane, + source_commit: request.source_commit.into(), + source_archive_sha256: request.source_archive_sha256.into(), + cargo_lock_sha256: actual_lock, + version: request.version.into(), + target: request.target.into(), + profile: request.profile.into(), + features, + rustc_verbose, + cargo, + baseline_executable_sha256: digest( + &fs::read(request.baseline_executable).map_err(display_error)?, + ), + image_digest: request.image_digest.into(), + packaging_tool, + native_tools, + artifacts: request + .artifacts + .iter() + .map(|path| artifact(path)) + .collect::>>()?, + }; + let bytes = canonical_json(&serde_json::to_value(evidence).map_err(display_error)?)?; + fs::write(request.output, bytes).map_err(display_error) +} + +fn command_evidence(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!( + "{program} evidence command mismatch: expected success, actual {}", + output.status + ))); + } + normalize_command_evidence( + program, + String::from_utf8(output.stdout).map_err(display_error)?, + ) +} + +fn command_first_line(program: &str, args: &[&str]) -> Result { + command_evidence(program, args)? + .lines() + .next() + .map(str::to_owned) + .ok_or_else(|| Error::new(format!("{program} identity mismatch: expected output"))) +} + +fn two_word_identity(value: &str, expected_name: &str) -> Result { + let mut words = value.split_whitespace(); + let name = words.next().unwrap_or_default(); + let version = words.next().unwrap_or_default(); + if name != expected_name || version.is_empty() { + return Err(Error::new(format!( + "{expected_name} identity mismatch: expected name and version, actual {value}" + ))); + } + Ok(format!("{name} {version}")) +} + +fn version_token(value: &str, expected_name: &str) -> Result { + Ok(two_word_identity(value, expected_name)? + .split_once(' ') + .unwrap() + .1 + .to_owned()) +} + +fn dpkg_deb_identity() -> Result { + let output = command_first_line("dpkg-deb", &["--version"])?; + let version = output + .split_once(" version ") + .and_then(|(_, tail)| tail.split_whitespace().next()) + .ok_or_else(|| { + Error::new("dpkg-deb identity mismatch: expected version, actual invalid") + })?; + Ok(format!("dpkg-deb {version}")) +} + +fn os_pretty_name() -> Result { + let body = fs::read_to_string("/etc/os-release").map_err(display_error)?; + let value = body + .lines() + .find_map(|line| line.strip_prefix("PRETTY_NAME=")) + .map(|value| value.trim_matches(['\'', '"']).to_owned()) + .ok_or_else(|| Error::new("OS identity mismatch: expected PRETTY_NAME, actual missing"))?; + validate_evidence_text("lane OS", &value)?; + Ok(value) +} + +fn observe_signing_mode(lane: Lane, package: &Path) -> Result { + let output = match lane { + Lane::Deb => Command::new("ar").arg("t").arg(package).output(), + Lane::Rpm => Command::new("rpm").arg("--checksig").arg(package).output(), + } + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!( + "package signature check mismatch: expected success, actual {}", + output.status + ))); + } + let text = String::from_utf8(output.stdout).map_err(display_error)?; + let lower = text.to_ascii_lowercase(); + let unsigned = match lane { + Lane::Deb => !text.lines().any(|line| line.starts_with("_gpg")), + Lane::Rpm => { + lower.contains("digests ok") + && !lower.contains("signature") + && !lower.contains("pgp") + && !lower.contains("gpg") + } + }; + if !unsigned { + return Err(Error::new( + "package signing mode mismatch: expected unsigned, actual signed or unknown", + )); + } + Ok("unsigned".into()) +} + +pub fn build_lane(request: &LaneRequest<'_>) -> Result { + require_image_digest(&request.ubuntu.digest)?; + require_image_digest(&request.fedora.digest)?; + if request.context.path == request.repo.path() + || request.context.path.starts_with(request.repo.path()) + && !request.context.path.starts_with( + request + .repo + .path() + .join("dist/.rust-release-candidate-staging"), + ) + { + return Err(Error::new( + "container build context mismatch: expected immutable export, actual live repository", + )); + } + require_directory(request.output, "lane output")?; + let file = request.context.path.join("packaging/Containerfile"); + require_regular(&file, "immutable Containerfile")?; + let file = path_text(&file, "Containerfile")?; + let output = path_text(request.output, "lane output")?; + let context = path_text(&request.context.path, "immutable context")?; + let mut args = match request.engine { + ContainerEngine::Podman => vec![ + "build".into(), + "--pull=never".into(), + "--network=none".into(), + "--file".into(), + file.into(), + "--target".into(), + request.lane.target().into(), + "--output".into(), + format!("type=local,dest={output}"), + ], + ContainerEngine::Docker => { + run_success( + request.processes, + request.repo.path(), + "docker", + &["buildx", "version"], + )?; + vec![ + "buildx".into(), + "build".into(), + "--pull=false".into(), + "--network=none".into(), + "--file".into(), + file.into(), + "--target".into(), + request.lane.target().into(), + "--output".into(), + format!("type=local,dest={output}"), + ] + } + }; + for (key, value) in [ + ("UBUNTU_TOOL_BASE", request.ubuntu.digest.as_str()), + ("FEDORA_TOOL_BASE", request.fedora.digest.as_str()), + ("INVOCATION_ID", request.invocation_id), + ("SOURCE_COMMIT", request.context.commit.as_str()), + ( + "SOURCE_ARCHIVE_SHA256", + request.context.archive_sha256.as_str(), + ), + ( + "CARGO_LOCK_SHA256", + request.context.cargo_lock_sha256.as_str(), + ), + ("RELEASE_VERSION", request.version), + ] { + args.extend(["--build-arg".into(), format!("{key}={value}")]); + } + args.push(context.into()); + run_success_owned( + request.processes, + &request.context.path, + request.engine.executable(), + &args, + )?; + let evidence = consume_lane_handoff(request)?; + validate_lane_inventory(request, LANE_EVIDENCE_NAME)?; + Ok(evidence) +} + +fn require_image_digest(value: &str) -> Result<()> { + if value + .strip_prefix("sha256:") + .is_none_or(|id| !is_sha256(id)) + { + return Err(Error::new(format!( + "image digest mismatch: expected sha256:<64 lowercase hex>, actual {value}" + ))); + } + Ok(()) +} + +fn consume_lane_handoff(request: &LaneRequest<'_>) -> Result { + let handoff = request.output.join(LANE_HANDOFF); + validate_lane_inventory(request, LANE_HANDOFF)?; + let evidence: LaneEvidence = strict_json_file(&handoff)?; + validate_lane_evidence(&evidence, request)?; + fs::rename(&handoff, request.output.join(LANE_EVIDENCE_NAME)).map_err(display_error)?; + Ok(evidence) +} + +fn normalize_command_evidence(label: &str, value: String) -> Result { + let normalized = value.trim_end_matches(['\r', '\n']).replace("\r\n", "\n"); + validate_evidence_text(label, &normalized)?; + Ok(normalized) +} + +fn validate_lane_inventory(request: &LaneRequest<'_>, evidence_name: &str) -> Result<()> { + let mut actual = BTreeSet::new(); + for entry in fs::read_dir(request.output).map_err(display_error)? { + let entry = entry.map_err(display_error)?; + require_regular(&entry.path(), "lane output")?; + actual.insert( + entry + .file_name() + .into_string() + .map_err(|_| Error::new("lane output mismatch: expected UTF-8 basename"))?, + ); + } + let mut expected = BTreeSet::from([evidence_name.to_owned()]); + expected.extend(expected_artifact_names(request.lane, request.version)); + if actual != expected { + return Err(Error::new(format!( + "lane output inventory mismatch: expected {expected:?}, actual {actual:?}" + ))); + } + Ok(()) +} + +fn expected_artifact_names(lane: Lane, version: &str) -> [String; 2] { + let tar = format!("solstone-linux-{version}-linux-x86_64.tar.gz"); + let native = match lane { + Lane::Deb => format!("solstone-linux_{version}-1_amd64.deb"), + Lane::Rpm => format!("solstone-linux-{version}-1.x86_64.rpm"), + }; + [tar, native] +} + +pub(crate) fn validate_lane_evidence( + evidence: &LaneEvidence, + request: &LaneRequest<'_>, +) -> Result<()> { + let expected_image = match request.lane { + Lane::Deb => &request.ubuntu.digest, + Lane::Rpm => &request.fedora.digest, + }; + let expected_tool = match request.lane { + Lane::Deb => "cargo-deb 3.7.0", + Lane::Rpm => "cargo-generate-rpm 0.21.0", + }; + let checks = [ + ( + evidence.invocation_id == request.invocation_id, + "invocation_id", + ), + (evidence.lane == request.lane, "lane"), + ( + evidence.source_commit == request.context.commit, + "source_commit", + ), + ( + evidence.source_archive_sha256 == request.context.archive_sha256, + "source_archive_sha256", + ), + ( + evidence.cargo_lock_sha256 == request.context.cargo_lock_sha256, + "cargo_lock_sha256", + ), + (evidence.version == request.version, "version"), + (evidence.target == TARGET_TRIPLE, "target"), + (evidence.profile == "release", "profile"), + (evidence.features.is_empty(), "features"), + (&evidence.image_digest == expected_image, "image_digest"), + (evidence.packaging_tool == expected_tool, "packaging_tool"), + ]; + if let Some((_, field)) = checks.into_iter().find(|(valid, _)| !valid) { + return Err(Error::new(format!("lane evidence {field} mismatch"))); + } + validate_evidence_text("lane rustc verbose", &evidence.rustc_verbose)?; + validate_evidence_text("lane cargo", &evidence.cargo)?; + let rustc_lines = evidence.rustc_verbose.lines().collect::>(); + if !evidence.rustc_verbose.starts_with("rustc 1.97.1 (") + || !rustc_lines.contains("host: x86_64-unknown-linux-gnu") + || !rustc_lines.contains("release: 1.97.1") + { + return Err(Error::new( + "lane rustc identity mismatch: expected 1.97.1 linux/amd64 verbose banner, actual different", + )); + } + if !evidence.cargo.starts_with("cargo 1.97.1 (") { + return Err(Error::new( + "lane Cargo identity mismatch: expected 1.97.1 banner, actual different", + )); + } + if !is_sha256(&evidence.baseline_executable_sha256) { + return Err(Error::new("lane baseline executable digest mismatch")); + } + validate_lane_native_tools(evidence, request)?; + let expected_kinds = match request.lane { + Lane::Deb => BTreeSet::from(["deb", "tar"]), + Lane::Rpm => BTreeSet::from(["rpm", "tar"]), + }; + let mut kinds = BTreeSet::new(); + for artifact_record in &evidence.artifacts { + let kind = artifact_kind(&artifact_record.path, Some(request.version))?; + require_regular( + &request.output.join(&artifact_record.path), + &artifact_record.path, + )?; + if artifact(&request.output.join(&artifact_record.path))? != *artifact_record { + return Err(Error::new(format!( + "lane artifact mismatch: expected evidence, actual {}", + artifact_record.path + ))); + } + if !kinds.insert(kind) { + return Err(Error::new("lane artifact mismatch: expected unique kinds")); + } + } + if kinds != expected_kinds || evidence.artifacts.len() != 2 { + return Err(Error::new("lane artifact inventory mismatch")); + } + Ok(()) +} + +fn validate_lane_native_tools(evidence: &LaneEvidence, request: &LaneRequest<'_>) -> Result<()> { + match (&evidence.native_tools, request.lane) { + (LaneNativeTools::Ubuntu(tools), Lane::Deb) => { + for (actual, expected, key) in [ + (tools.cargo_deb.as_str(), "3.7.0", "cargo_deb"), + (tools.signing_mode.as_str(), "unsigned", "signing_mode"), + (tools.ubuntu_rustc.as_str(), "rustc 1.97.1", "ubuntu_rustc"), + (tools.ubuntu_cargo.as_str(), "cargo 1.97.1", "ubuntu_cargo"), + ] { + if actual != expected { + return Err(Error::new(format!( + "lane native tool {key} mismatch: expected {expected}, actual {actual}" + ))); + } + } + let expected_image = request.ubuntu.digest.strip_prefix("sha256:").unwrap(); + if tools.ubuntu_image_digest != expected_image { + return Err(Error::new(format!( + "lane native tool ubuntu_image_digest mismatch: expected {expected_image}, actual {}", + tools.ubuntu_image_digest + ))); + } + for (key, value) in [ + ("dpkg_deb", tools.dpkg_deb.as_str()), + ("ubuntu_os", tools.ubuntu_os.as_str()), + ("ubuntu_compiler", tools.ubuntu_compiler.as_str()), + ("ubuntu_linker", tools.ubuntu_linker.as_str()), + ("ubuntu_glibc", tools.ubuntu_glibc.as_str()), + ("ubuntu_tar", tools.ubuntu_tar.as_str()), + ("ubuntu_gzip", tools.ubuntu_gzip.as_str()), + ] { + validate_identity(key, value)?; + } + } + (LaneNativeTools::Fedora(tools), Lane::Rpm) => { + for (actual, expected, key) in [ + ( + tools.cargo_generate_rpm.as_str(), + "0.21.0", + "cargo_generate_rpm", + ), + (tools.signing_mode.as_str(), "unsigned", "signing_mode"), + ] { + if actual != expected { + return Err(Error::new(format!( + "lane native tool {key} mismatch: expected {expected}, actual {actual}" + ))); + } + } + let expected_image = request.fedora.digest.strip_prefix("sha256:").unwrap(); + if tools.fedora_image_digest != expected_image { + return Err(Error::new(format!( + "lane native tool fedora_image_digest mismatch: expected {expected_image}, actual {}", + tools.fedora_image_digest + ))); + } + for (key, value) in [ + ("fedora_os", tools.fedora_os.as_str()), + ("rpm", tools.rpm.as_str()), + ] { + validate_identity(key, value)?; + } + } + _ => { + return Err(Error::new( + "lane native tools mismatch: expected lane-specific evidence, actual cross-wired", + )); + } + } + Ok(()) +} + +pub fn assemble_manifest_native_tools( + root: &RepoRoot, + deb: &LaneEvidence, + rpm: &LaneEvidence, + container_engine: String, +) -> Result> { + validate_identity("container_engine", &container_engine)?; + let (LaneNativeTools::Ubuntu(ubuntu), LaneNativeTools::Fedora(fedora)) = + (&deb.native_tools, &rpm.native_tools) + else { + return Err(Error::new( + "manifest native tools mismatch: expected Ubuntu and Fedora lanes, actual cross-wired", + )); + }; + if ubuntu.signing_mode != fedora.signing_mode { + return Err(Error::new(format!( + "signing mode mismatch: expected {}, actual {}", + ubuntu.signing_mode, fedora.signing_mode + ))); + } + let mut tools = serde_json::from_value::>( + serde_json::to_value(ubuntu).map_err(display_error)?, + ) + .map_err(display_error)?; + tools.extend( + serde_json::from_value::>( + serde_json::to_value(fedora).map_err(display_error)?, + ) + .map_err(display_error)?, + ); + tools.insert("container_engine".into(), container_engine); + tools.insert( + "manifest_validator".into(), + env!("CARGO_PKG_VERSION").into(), + ); + validate_native_tools(root, &tools)?; + Ok(tools) +} + +pub fn reconcile_lanes( + deb: &LaneEvidence, + rpm: &LaneEvidence, + deb_root: &Path, + rpm_root: &Path, +) -> Result<()> { + if deb.lane != Lane::Deb || rpm.lane != Lane::Rpm || deb.invocation_id != rpm.invocation_id { + return Err(Error::new( + "lane reconciliation mismatch: expected paired lanes", + )); + } + for (matches, field) in [ + (deb.rustc_verbose == rpm.rustc_verbose, "rustc_verbose"), + (deb.cargo == rpm.cargo, "cargo"), + (deb.target == rpm.target, "target"), + (deb.profile == rpm.profile, "profile"), + (deb.features == rpm.features, "features"), + (deb.source_commit == rpm.source_commit, "source_commit"), + ( + deb.source_archive_sha256 == rpm.source_archive_sha256, + "source_archive_sha256", + ), + ( + deb.cargo_lock_sha256 == rpm.cargo_lock_sha256, + "cargo_lock_sha256", + ), + ( + deb.baseline_executable_sha256 == rpm.baseline_executable_sha256, + "baseline_executable_sha256", + ), + ] { + if !matches { + return Err(Error::new(format!("lane reconciliation {field} mismatch"))); + } + } + let deb_tar = deb + .artifacts + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some("tar")) + .ok_or_else(|| Error::new("lane tar mismatch: expected deb tar, actual missing"))?; + let rpm_tar = rpm + .artifacts + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some("tar")) + .ok_or_else(|| Error::new("lane tar mismatch: expected rpm tar, actual missing"))?; + if deb_tar != rpm_tar + || fs::read(deb_root.join(&deb_tar.path)).map_err(display_error)? + != fs::read(rpm_root.join(&rpm_tar.path)).map_err(display_error)? + { + return Err(Error::new( + "lane tar mismatch: expected byte-identical, actual different", + )); + } + Ok(()) +} + +pub fn recheck_images( + processes: &ProcessEnvironment, + engine: ContainerEngine, + expected: [&ImageIdentity; 2], +) -> Result<()> { + for identity in expected { + let actual = inspect_image(processes, engine, &identity.configured_tag)?; + if actual.digest != identity.digest { + return Err(Error::new(format!( + "image identity mismatch: expected {}, actual {}", + identity.digest, actual.digest + ))); + } + } + Ok(()) +} + +fn strict_json_file(path: &Path) -> Result { + require_regular(path, "JSON input")?; + serde_json::from_slice(&fs::read(path).map_err(display_error)?).map_err(display_error) +} + +pub(crate) fn require_commit(value: &str, label: &str) -> Result<()> { + if !is_git_commit(value) { + return Err(Error::new(format!( + "{label} mismatch: expected 40 or 64 lowercase hexadecimal characters, actual {value}" + ))); + } + Ok(()) +} + +fn path_text<'a>(path: &'a Path, label: &str) -> Result<&'a str> { + path.to_str() + .ok_or_else(|| Error::new(format!("{label} mismatch: expected UTF-8 path"))) +} + +fn run_success( + processes: &ProcessEnvironment, + root: &Path, + program: &str, + args: &[&str], +) -> Result<()> { + run_output(processes, root, program, args).map(|_| ()) +} + +pub(crate) fn run_success_owned( + processes: &ProcessEnvironment, + root: &Path, + program: &str, + args: &[String], +) -> Result<()> { + let output = processes + .command(program) + .args(args) + .current_dir(root) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!( + "{program} command mismatch: expected success, actual {}", + output.status + ))); + } + Ok(()) +} + +fn run_stdout( + processes: &ProcessEnvironment, + root: &Path, + program: &str, + args: &[&str], +) -> Result { + String::from_utf8(run_output(processes, root, program, args)?.stdout) + .map(|value| value.trim().to_owned()) + .map_err(display_error) +} + +fn run_output( + processes: &ProcessEnvironment, + root: &Path, + program: &str, + args: &[&str], +) -> Result { + let output = processes + .command(program) + .args(args) + .current_dir(root) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!( + "{program} command mismatch: expected success, actual {}", + output.status + ))); + } + Ok(output) +} diff --git a/crates/rust-release-manifest/src/candidate_tests.rs b/crates/rust-release-manifest/src/candidate_tests.rs new file mode 100644 index 0000000..49478b4 --- /dev/null +++ b/crates/rust-release-manifest/src/candidate_tests.rs @@ -0,0 +1,1877 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use chrono::Utc; +use std::ffi::OsStr; +use std::os::unix::fs::PermissionsExt; +use std::process::Output; +use std::sync::OnceLock; + +static ARCHIVE: OnceLock> = OnceLock::new(); + +pub(super) struct TestRepo { + _temp: tempfile::TempDir, + pub root: RepoRoot, + pub commit: String, + pub cargo_lock_sha256: String, + pub cargo_deny_version: String, + pub exceptions: Vec, +} + +pub(super) fn fixture() -> TestRepo { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap(); + let archive = ARCHIVE.get_or_init(|| { + Command::new("git") + .args(["archive", "--format=tar", "HEAD"]) + .current_dir(&source) + .output() + .unwrap() + .stdout + }); + let temp = tempfile::tempdir().unwrap(); + Archive::new(Cursor::new(archive)) + .unpack(temp.path()) + .unwrap(); + let image_policy = Path::new("packaging/release-policy.toml"); + if !temp.path().join(image_policy).exists() { + fs::copy(source.join(image_policy), temp.path().join(image_policy)).unwrap(); + } + for args in [ + &["init", "-q"][..], + &["config", "user.email", "release-fixture@invalid.example"][..], + &["config", "user.name", "Release Fixture"][..], + &["add", "--all"][..], + &["commit", "-q", "-m", "fixture"][..], + ] { + let mut command = Command::new("git"); + command.args(args).current_dir(temp.path()); + if args.first() == Some(&"commit") { + command + .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"); + } + assert!(command.status().unwrap().success()); + } + let root = RepoRoot::validate_path(temp.path()).unwrap(); + let commit = command(root.path(), &["git", "rev-parse", "HEAD"]).unwrap(); + let cargo_lock_sha256 = digest(&fs::read(root.path().join("Cargo.lock")).unwrap()); + let makefile = fs::read_to_string(root.path().join("Makefile")).unwrap(); + let cargo_deny_version = makefile + .lines() + .find_map(|line| line.strip_prefix("CARGO_DENY_VERSION := ")) + .unwrap() + .to_owned(); + let exceptions = ordered_exceptions(&root).unwrap(); + TestRepo { + _temp: temp, + root, + commit, + cargo_lock_sha256, + cargo_deny_version, + exceptions, + } +} + +pub(super) fn sha256_fixture() -> TestRepo { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap(); + let archive = Command::new("git") + .args(["archive", "--format=tar", "HEAD"]) + .current_dir(&source) + .output() + .unwrap() + .stdout; + let temp = tempfile::tempdir().unwrap(); + Archive::new(Cursor::new(archive)) + .unpack(temp.path()) + .unwrap(); + let image_policy = Path::new("packaging/release-policy.toml"); + if !temp.path().join(image_policy).exists() { + fs::copy(source.join(image_policy), temp.path().join(image_policy)).unwrap(); + } + assert!( + Command::new("git") + .args(["init", "-q", "--object-format=sha256"]) + .current_dir(temp.path()) + .status() + .unwrap() + .success() + ); + for args in [ + &["config", "user.email", "release-fixture@invalid.example"][..], + &["config", "user.name", "Release Fixture"][..], + &["add", "--all"][..], + &["commit", "-q", "-m", "fixture"][..], + ] { + assert!( + Command::new("git") + .args(args) + .current_dir(temp.path()) + .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z") + .status() + .unwrap() + .success() + ); + } + let root = RepoRoot::validate_path(temp.path()).unwrap(); + let commit = command(root.path(), &["git", "rev-parse", "HEAD"]).unwrap(); + let cargo_lock_sha256 = digest(&fs::read(root.path().join("Cargo.lock")).unwrap()); + let makefile = fs::read_to_string(root.path().join("Makefile")).unwrap(); + let cargo_deny_version = makefile + .lines() + .find_map(|line| line.strip_prefix("CARGO_DENY_VERSION := ")) + .unwrap() + .into(); + let exceptions = ordered_exceptions(&root).unwrap(); + TestRepo { + _temp: temp, + root, + commit, + cargo_lock_sha256, + cargo_deny_version, + exceptions, + } +} + +pub(super) struct StubPath { + _temp: tempfile::TempDir, + pub bin: PathBuf, + pub argv: PathBuf, + pub tripwire: PathBuf, + pub output: PathBuf, +} + +impl StubPath { + pub fn new(name: &str, output_fixture: Option<&Path>) -> Self { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let argv = temp.path().join("argv"); + let tripwire = temp.path().join("tripwire"); + let output = temp.path().join("output"); + let executable = bin.join(name); + let fixture = output_fixture.map_or_else(String::new, |path| path.display().to_string()); + fs::write( + &executable, + format!( + "#!/bin/sh\nprintf '%s\\0' \"$PWD\" \"$@\" > \"$ARGV_RECORD\"\n\ + if [ -n \"$TRIPWIRE\" ]; then printf called > \"$TRIPWIRE\"; fi\n\ + if [ -n '{fixture}' ]; then cp '{fixture}' \"$STUB_OUTPUT\"; fi\n" + ), + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + Self { + _temp: temp, + bin, + argv, + tripwire, + output, + } + } + + pub fn command(&self, name: &str) -> Command { + let mut command = Command::new(self.bin.join(name)); + command + .env("ARGV_RECORD", &self.argv) + .env("TRIPWIRE", &self.tripwire) + .env("STUB_OUTPUT", &self.output); + command + } + + pub fn run(&self, name: &str, args: &[&str]) -> Output { + self.command(name).args(args).output().unwrap() + } +} + +#[test] +fn fixture_binds_real_repository_authorities() { + let repo = fixture(); + assert_eq!(repo.commit.len(), 40); + assert!(is_sha256(&repo.cargo_lock_sha256)); + assert_eq!(repo.cargo_deny_version, CARGO_DENY_VERSION); + assert_eq!(repo.exceptions, EXCEPTIONS); +} + +#[test] +fn path_stub_records_nul_safe_argv_and_cwd() { + let stub = StubPath::new("tool", None); + assert!(stub.run("tool", &["one", "two words"]).status.success()); + let bytes = fs::read(&stub.argv).unwrap(); + let fields = bytes.split(|byte| *byte == 0).collect::>(); + assert_eq!(fields[1], b"one"); + assert_eq!(fields[2], b"two words"); + assert!(stub.tripwire.exists()); +} + +#[test] +fn exclusive_lock_loser_mutates_nothing() { + let repo = fixture(); + fs::create_dir(repo.root.path().join("dist")).unwrap(); + let owner = CandidateLock::acquire(&repo.root).unwrap(); + let before = fs::read_dir(repo.root.path().join("dist")).unwrap().count(); + assert!(CandidateLock::acquire(&repo.root).is_err()); + assert_eq!( + fs::read_dir(repo.root.path().join("dist")).unwrap().count(), + before + ); + drop(owner); +} + +#[test] +fn staging_and_emptying_paths_are_owner_scoped() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + assert!( + staging.root.starts_with( + repo.root + .path() + .join("dist/.rust-release-candidate-staging") + ) + ); + for path in [ + &staging.context, + &staging.deb_lane, + &staging.rpm_lane, + &staging.advisory_db, + &staging.payload, + &staging.proofs, + ] { + assert!(path.is_dir()); + } + let id = staging.root.file_name().unwrap().to_str().unwrap(); + let allowlist = owner_emptying_allowlist(&repo.root, "1.0.0", id).unwrap(); + assert_eq!(allowlist.len(), 4); + assert!(!allowlist.contains(&repo.root.path().join("dist"))); + assert!(allowlist.contains(&staging.root)); +} + +#[test] +fn staging_construction_failure_cleans_only_owned_root_and_reports_residue() { + let repo = fixture(); + let parent = repo + .root + .path() + .join("dist/.rust-release-candidate-staging"); + fs::create_dir_all(&parent).unwrap(); + let sibling = parent.join("foreign"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + + let owned = parent.join("owned-mid-failure"); + fs::create_dir(&owned).unwrap(); + fs::write(owned.join("lane-rpm"), b"blocks directory creation").unwrap(); + assert!(StagingLayout::initialize_owned(owned.clone()).is_err()); + assert!(!owned.exists()); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); + assert!(!repo.root.path().join("dist/rust").exists()); + assert!(!repo.root.path().join("dist/rust-evidence").exists()); + + let residue = parent.join("owned-cleanup-failure"); + fs::create_dir(&residue).unwrap(); + fs::write(residue.join("lane-rpm"), b"blocks directory creation").unwrap(); + fs::set_permissions(&residue, fs::Permissions::from_mode(0o555)).unwrap(); + let error = StagingLayout::initialize_owned(residue.clone()).unwrap_err(); + fs::set_permissions(&residue, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(error.to_string().contains("Permission denied")); + assert!(error.to_string().contains("repair: remove")); + assert!(residue.exists()); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); + fs::remove_dir_all(residue).unwrap(); +} + +#[test] +fn controlled_rollback_removes_only_owned_payload_and_evidence() { + let temp = tempfile::tempdir().unwrap(); + let payload = temp.path().join("dist/rust"); + let evidence = temp.path().join("dist/rust-evidence/1.0.0"); + let proofs = evidence.join("proofs"); + fs::create_dir_all(&payload).unwrap(); + fs::create_dir_all(&proofs).unwrap(); + fs::write(payload.join("candidate"), b"bytes").unwrap(); + fs::write(evidence.join("ledger.json"), b"ledger").unwrap(); + let owned = proofs.join("debian-amd64.json"); + fs::write(&owned, b"proof").unwrap(); + let unowned = temp.path().join("dist/rust-evidence/other/ledger.json"); + fs::create_dir_all(unowned.parent().unwrap()).unwrap(); + fs::write(&unowned, b"retain").unwrap(); + let error = rollback_error( + Error::new("controlled failure"), + &payload, + &evidence, + &[owned], + ); + assert_eq!(error.to_string(), "controlled failure"); + assert!(!payload.exists()); + assert!(!evidence.exists()); + assert_eq!(fs::read(unowned).unwrap(), b"retain"); +} + +#[test] +fn controlled_rollback_reports_exact_residue() { + let temp = tempfile::tempdir().unwrap(); + let payload = temp.path().join("dist/rust"); + fs::create_dir_all(&payload).unwrap(); + fs::set_permissions(payload.parent().unwrap(), fs::Permissions::from_mode(0o555)).unwrap(); + let evidence = temp.path().join("dist/rust-evidence/1.0.0"); + let error = rollback_error(Error::new("controlled failure"), &payload, &evidence, &[]); + fs::set_permissions(payload.parent().unwrap(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + error + .to_string() + .contains(&format!("residue at {}", payload.display())) + ); + assert!(error.to_string().contains("repair:")); +} + +#[test] +fn clean_tree_accepts_an_absent_ignored_candidate_path() { + let repo = fixture(); + let dist = repo.root.path().join("dist"); + fs::create_dir(&dist).unwrap(); + let payload = dist.join("rust"); + assert!(!payload.exists()); + require_clean_tree(repo.root.path(), &payload).unwrap(); + assert!(!payload.exists()); +} + +#[test] +fn immutable_context_uses_committed_archive() { + let repo = fixture(); + fs::write(repo.root.path().join("UNTRACKED_CANARY"), b"ambient").unwrap(); + let destination = tempfile::tempdir().unwrap(); + let context = export_immutable_context(&repo.root, destination.path()).unwrap(); + assert_eq!(context.commit, repo.commit); + assert_eq!(context.cargo_lock_sha256, repo.cargo_lock_sha256); + assert!(!destination.path().join("UNTRACKED_CANARY").exists()); +} + +#[test] +fn release_image_policy_is_digest_only_strict_and_immutable() { + let repo = fixture(); + let destination = tempfile::tempdir().unwrap(); + let context = export_immutable_context(&repo.root, destination.path()).unwrap(); + let expected = ReleaseImages::from_context(&context).unwrap(); + fs::write( + repo.root.path().join("packaging/release-policy.toml"), + "build_ubuntu = \"ubuntu:latest\"\n", + ) + .unwrap(); + assert_eq!(ReleaseImages::from_context(&context).unwrap(), expected); + + let policy = context.path.join("packaging/release-policy.toml"); + let valid = fs::read(&policy).unwrap(); + for invalid in [ + "", + "build_ubuntu = \"ubuntu:22.04\"\nbuild_fedora = \"fedora:42\"\nproof_debian = \"ubuntu:22.04\"\nproof_rpm = \"fedora:42\"\nproof_tar = \"ubuntu:22.04\"\n", + "build_ubuntu = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n", + "build_ubuntu = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\nbuild_fedora = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\nproof_debian = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\nproof_rpm = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\nproof_tar = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\nunknown = \"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"\n", + "build_ubuntu = \"--image\"\nbuild_fedora = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\nproof_debian = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\nproof_rpm = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\nproof_tar = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n", + ] { + fs::write(&policy, invalid).unwrap(); + assert!(ReleaseImages::from_context(&context).is_err()); + } + fs::write(&policy, valid).unwrap(); +} + +#[test] +fn release_image_resolution_rejects_absent_and_mismatched_local_ids() { + let proof_policy = |image_digest: String| ProofPlatformPolicy { + image_digest, + os_release: "Ubuntu 22.04.5 LTS".into(), + package_manager_version: "dpkg 1".into(), + install_command: vec!["install".into()], + version_command: vec!["version".into()], + executable_path: "/usr/bin/solstone-linux".into(), + executable_mode: 0o755, + }; + let images = ReleaseImages { + build_ubuntu: format!("sha256:{}", "a".repeat(64)), + build_fedora: format!("sha256:{}", "b".repeat(64)), + proof_debian: format!("sha256:{}", "a".repeat(64)), + proof_rpm: format!("sha256:{}", "b".repeat(64)), + proof_tar: format!("sha256:{}", "a".repeat(64)), + debian_amd64: proof_policy(format!("sha256:{}", "a".repeat(64))), + rpm_x86_64: proof_policy(format!("sha256:{}", "b".repeat(64))), + tar_x86_64: proof_policy(format!("sha256:{}", "a".repeat(64))), + }; + let mismatch = format!("#!/bin/sh\nprintf '%s' '{}'\n", image_json('c')); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&mismatch)); + assert!(resolve_release_images(&processes, ContainerEngine::Podman, &images).is_err()); + let (_bin, absent) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 44\n")); + assert!(resolve_release_images(&absent, ContainerEngine::Podman, &images).is_err()); +} + +fn executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +fn process_bin( + cargo_body: &str, + podman_body: Option<&str>, +) -> (tempfile::TempDir, ProcessEnvironment) { + let temp = tempfile::tempdir().unwrap(); + executable(&temp.path().join("cargo"), cargo_body); + if let Some(body) = podman_body { + executable(&temp.path().join("podman"), body); + } + let path = format!("{}:/usr/bin:/bin", temp.path().display()); + (temp, ProcessEnvironment::with_path(OsStr::new(&path))) +} + +pub(super) fn git_repo() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + for args in [ + &["init", "-q"][..], + &["config", "user.email", "advisory@invalid.example"][..], + &["config", "user.name", "Advisory Fixture"][..], + ] { + assert!( + Command::new("git") + .args(args) + .current_dir(temp.path()) + .status() + .unwrap() + .success() + ); + } + fs::create_dir_all(temp.path().join("crates/example")).unwrap(); + fs::write( + temp.path().join("crates/example/RUSTSEC-2026-0001.md"), + "fixture\n", + ) + .unwrap(); + for args in [ + &["add", "--all"][..], + &["commit", "-q", "-m", "advisories"][..], + ] { + assert!( + Command::new("git") + .args(args) + .current_dir(temp.path()) + .status() + .unwrap() + .success() + ); + } + temp +} + +pub(super) fn descriptor( + root: &Path, + db: &Path, + extra: Option<(&str, Value)>, + acquired_at: &str, +) -> PathBuf { + let mut value = serde_json::json!({ + "schema_version": 1, + "source_id": "rustsec snapshot 1", + "db_path": db, + "acquired_at": acquired_at, + }); + if let Some((key, extra)) = extra { + value[key] = extra; + } + let path = root.join("advisory-descriptor.json"); + fs::write(path.as_path(), serde_json::to_vec(&value).unwrap()).unwrap(); + path +} + +pub(super) fn current_time() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +const CARGO_DENY_ASSERTIONS: &str = r#"#!/bin/sh +printf '%s\n' "$*" >> "$PWD/cargo-deny-argv" +case " $* " in + *" deny --locked --offline --config "*" check licenses bans sources "*) ;; + *" deny --locked --offline --config "*" check advisories "*) + config="$5" + grep -F 'db-urls = ["file://localhost/advisory-db"]' "$config" >/dev/null || exit 91 + grep -F 'maximum-db-staleness = "1d"' "$config" >/dev/null || exit 92 + grep -F 'github.com/RustSec' "$config" >/dev/null && exit 93 + ;; + *) exit 94 ;; +esac +exit 0 +"#; + +#[test] +fn advisory_cohort_is_clean_local_ordered_and_offline() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let db = git_repo(); + let descriptor = descriptor(&staging.root, db.path(), None, ¤t_time()); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, None); + let before = fs::read(repo.root.path().join("deny.toml")).unwrap(); + let cohort = run_advisory_cohort(&context, &staging, &descriptor, &processes).unwrap(); + assert_eq!(cohort.deterministic_gate, "pass"); + assert_eq!(cohort.licenses_bans_sources, "pass"); + assert_eq!(cohort.advisories, "pass"); + assert_eq!( + fs::read(repo.root.path().join("deny.toml")).unwrap(), + before + ); + let calls = fs::read_to_string(context.path.join("cargo-deny-argv")).unwrap(); + let calls = calls.lines().collect::>(); + assert_eq!(calls.len(), 2); + assert!(calls[0].ends_with("check licenses bans sources")); + assert!(calls[1].ends_with("check advisories")); + let config = fs::read_to_string(staging.root.join("advisory-deny.toml")).unwrap(); + assert!(config.contains("db-path = \"")); + assert!(config.contains("db-urls = [\"file://localhost/advisory-db\"]")); + assert!(!config.contains("github.com/RustSec")); + assert!( + !serde_json::to_string(&cohort.source_id) + .unwrap() + .contains(db.path().to_str().unwrap()) + ); +} + +#[test] +fn advisory_descriptor_rejects_absent_stale_dirty_and_fabricated_pass() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, None); + for case in ["absent", "stale", "dirty", "fabricated"] { + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let db = git_repo(); + let missing = staging.root.join("missing-db"); + let db_path = if case == "absent" { + missing.as_path() + } else { + db.path() + }; + if case == "dirty" { + fs::write(db.path().join("DIRTY"), b"dirty").unwrap(); + } + let acquired = if case == "stale" { + "2020-01-01T00:00:00Z".to_owned() + } else { + current_time() + }; + let extra = + (case == "fabricated").then(|| ("deterministic_gate", Value::String("pass".into()))); + let path = descriptor(&staging.root, db_path, extra, &acquired); + assert!( + run_advisory_cohort(&context, &staging, &path, &processes).is_err(), + "accepted {case}" + ); + } +} + +#[test] +fn resume_accepts_aged_identity_matching_advisory_material() { + let db = git_repo(); + let input = tempfile::tempdir().unwrap(); + let descriptor = descriptor(input.path(), db.path(), None, "2020-01-01T00:00:00Z"); + let processes = ProcessEnvironment::default(); + assert!(validate_advisory_descriptor_identity(&descriptor, &processes).is_err()); + let identity = validate_resume_advisory_identity(&descriptor, &processes).unwrap(); + assert_eq!(identity.source_id, "rustsec snapshot 1"); +} + +#[test] +fn advisory_paths_and_ambient_git_overrides_fail_closed() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + for db_path in [ + PathBuf::from("relative/db"), + PathBuf::from("--git-dir=escape"), + ] { + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let path = descriptor(&staging.root, &db_path, None, ¤t_time()); + let (bin, processes) = process_bin("#!/bin/sh\nexit 99\n", None); + let tripwire = staging.root.join("git-tripwire"); + executable( + &bin.path().join("git"), + &format!( + "#!/bin/sh\nprintf called > '{}'\nexit 99\n", + tripwire.display() + ), + ); + assert!(run_advisory_cohort(&context, &staging, &path, &processes).is_err()); + assert!(!tripwire.exists()); + } + + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let db = git_repo(); + let path = descriptor(&staging.root, db.path(), None, ¤t_time()); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, None); + let processes = processes.with_git_canaries( + OsStr::new("/forbidden/git-dir"), + OsStr::new("/forbidden/git-work-tree"), + ); + run_advisory_cohort(&context, &staging, &path, &processes).unwrap(); +} + +#[test] +fn advisory_uses_exported_policy_and_lock_and_rechecks_snapshot() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let expected_lock = digest(&fs::read(context.path.join("Cargo.lock")).unwrap()); + fs::write(repo.root.path().join("Cargo.lock"), b"LIVE LOCK MUTATION").unwrap(); + fs::write(repo.root.path().join("deny.toml"), b"LIVE POLICY MUTATION").unwrap(); + let db = git_repo(); + let path = descriptor(&staging.root, db.path(), None, ¤t_time()); + let cargo = format!( + "#!/bin/sh\n[ \"$(sha256sum Cargo.lock | cut -d' ' -f1)\" = '{expected_lock}' ] || exit 91\ncase \" $* \" in *' check licenses bans sources '*) [ \"$5\" = '{}/deny.toml' ] || exit 92;; *' check advisories '*) grep -F 'file://localhost/advisory-db' \"$5\" >/dev/null || exit 93;; *) exit 94;; esac\n", + context.path.display() + ); + let (_bin, processes) = process_bin(&cargo, None); + let cohort = run_advisory_cohort(&context, &staging, &path, &processes).unwrap(); + recheck_advisory_cohort(&cohort, &processes, Utc::now()).unwrap(); + fs::write(db.path().join("DRIFT"), b"changed").unwrap(); + assert!(recheck_advisory_cohort(&cohort, &processes, Utc::now()).is_err()); +} + +#[test] +fn advisory_policy_failures_and_wrong_materialization_fail_closed() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + for failure in ["licenses", "bans", "sources"] { + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let db = git_repo(); + let path = descriptor(&staging.root, db.path(), None, ¤t_time()); + let script = + format!("#!/bin/sh\ncase \" $* \" in *' {failure} '*) exit 97;; esac\nexit 0\n"); + let (_bin, processes) = process_bin(&script, None); + assert!(run_advisory_cohort(&context, &staging, &path, &processes).is_err()); + } + + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + fs::create_dir(staging.advisory_db.join("advisory-db-wrong")).unwrap(); + let db = git_repo(); + let path = descriptor(&staging.root, db.path(), None, ¤t_time()); + let rejecting = "#!/bin/sh\nconfig=\"$5\"\ngrep -F 'file://localhost/advisory-db' \"$config\" >/dev/null || exit 98\nexit 99\n"; + let (_bin, processes) = process_bin(rejecting, None); + assert!(run_advisory_cohort(&context, &staging, &path, &processes).is_err()); + + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let db = git_repo(); + let path = descriptor(&staging.root, db.path(), None, ¤t_time()); + let (bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, None); + executable( + &bin.path().join("git"), + "#!/bin/sh\n/usr/bin/git \"$@\" || exit $?\nif [ \"$1\" = clone ]; then dest=; for arg in \"$@\"; do dest=$arg; done; printf swapped > \"$dest/SWAPPED\"; fi\n", + ); + assert!(run_advisory_cohort(&context, &staging, &path, &processes).is_err()); +} + +fn image_json(id: char) -> String { + format!( + "[{{\"Id\":\"{}\",\"Os\":\"linux\",\"Architecture\":\"amd64\"}}]", + id.to_string().repeat(64) + ) +} + +pub(super) fn lane_tools(lane: Lane, image_digest: &str) -> LaneNativeTools { + let image = image_digest.strip_prefix("sha256:").unwrap().to_owned(); + match lane { + Lane::Deb => LaneNativeTools::Ubuntu(UbuntuLaneTools { + cargo_deb: "3.7.0".into(), + dpkg_deb: "dpkg-deb 1.21.1".into(), + signing_mode: "unsigned".into(), + ubuntu_cargo: "cargo 1.97.1".into(), + ubuntu_compiler: "gcc 11.4.0".into(), + ubuntu_glibc: "glibc 2.35".into(), + ubuntu_gzip: "gzip 1.10".into(), + ubuntu_image_digest: image, + ubuntu_linker: "GNU ld 2.38".into(), + ubuntu_os: "Ubuntu 22.04".into(), + ubuntu_rustc: "rustc 1.97.1".into(), + ubuntu_tar: "GNU tar 1.34".into(), + }), + Lane::Rpm => LaneNativeTools::Fedora(FedoraLaneTools { + cargo_generate_rpm: "0.21.0".into(), + fedora_image_digest: image, + fedora_os: "Fedora 42".into(), + rpm: "RPM 4.20.0".into(), + signing_mode: "unsigned".into(), + }), + } +} + +#[test] +fn image_inspection_and_recheck_are_immutable_and_local() { + let script = format!( + "#!/bin/sh\ncase \"$*\" in '--version') printf '%s' 'podman version 5.8.3' ;; 'image inspect ubuntu:tool') printf '%s' '{}' ;; 'image inspect fedora:tool') printf '%s' '{}' ;; *) exit 97;; esac\n", + image_json('a'), + image_json('b') + ); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&script)); + let ubuntu = inspect_image(&processes, ContainerEngine::Podman, "ubuntu:tool").unwrap(); + let fedora = inspect_image(&processes, ContainerEngine::Podman, "fedora:tool").unwrap(); + assert_eq!(ubuntu.digest, format!("sha256:{}", "a".repeat(64))); + assert_eq!( + observe_container_engine(&processes, ContainerEngine::Podman).unwrap(), + "podman version 5.8.3" + ); + recheck_images(&processes, ContainerEngine::Podman, [&ubuntu, &fedora]).unwrap(); + + let changed = format!("#!/bin/sh\nprintf '%s' '{}'\n", image_json('c')); + let (_bin, changed_processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&changed)); + assert!( + recheck_images( + &changed_processes, + ContainerEngine::Podman, + [&ubuntu, &fedora] + ) + .is_err() + ); + let (_bin, missing) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 44\n")); + assert!(inspect_image(&missing, ContainerEngine::Podman, "missing:tool").is_err()); +} + +pub(super) fn lane_fixture( + root: &Path, + lane: Lane, + context: &ImmutableContext, + invocation: &str, + image: &str, + tar_bytes: &[u8], +) -> LaneEvidence { + let version = "1.0.0"; + let tar_name = format!("solstone-linux-{version}-linux-x86_64.tar.gz"); + fs::write(root.join(&tar_name), tar_bytes).unwrap(); + let package_name = match lane { + Lane::Deb => format!("solstone-linux_{version}-1_amd64.deb"), + Lane::Rpm => format!("solstone-linux-{version}-1.x86_64.rpm"), + }; + fs::write(root.join(&package_name), b"package").unwrap(); + let evidence = LaneEvidence { + invocation_id: invocation.into(), + lane, + source_commit: context.commit.clone(), + source_archive_sha256: context.archive_sha256.clone(), + cargo_lock_sha256: context.cargo_lock_sha256.clone(), + version: version.into(), + target: TARGET_TRIPLE.into(), + profile: "release".into(), + features: vec![], + rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), + cargo: "cargo 1.97.1 (abcdef012 2026-06-30)".into(), + baseline_executable_sha256: "d".repeat(64), + image_digest: image.into(), + packaging_tool: match lane { + Lane::Deb => "cargo-deb 3.7.0", + Lane::Rpm => "cargo-generate-rpm 0.21.0", + } + .into(), + native_tools: lane_tools(lane, image), + artifacts: vec![ + artifact(&root.join(&tar_name)).unwrap(), + artifact(&root.join(&package_name)).unwrap(), + ], + }; + fs::write( + root.join(LANE_EVIDENCE_NAME), + serde_json::to_vec(&evidence).unwrap(), + ) + .unwrap(); + evidence +} + +fn finalized_lane_fixture( + output: &Path, + lane: Lane, + context: &ImmutableContext, + image: &str, +) -> LaneEvidence { + let products = crate::tests::release_fixture(); + let tar = "solstone-linux-1.0.0-linux-x86_64.tar.gz"; + let package = match lane { + Lane::Deb => "solstone-linux_1.0.0-1_amd64.deb", + Lane::Rpm => "solstone-linux-1.0.0-1.x86_64.rpm", + }; + fs::copy(products.path().join(tar), output.join(tar)).unwrap(); + fs::copy(products.path().join(package), output.join(package)).unwrap(); + let mut evidence = lane_fixture( + output, + lane, + context, + "0123456789abcdef0123456789abcdef", + image, + &fs::read(products.path().join(tar)).unwrap(), + ); + fs::copy(products.path().join(package), output.join(package)).unwrap(); + evidence.artifacts = [tar, package] + .iter() + .map(|name| artifact(&output.join(name)).unwrap()) + .collect(); + fs::write( + output.join(LANE_EVIDENCE_NAME), + serde_json::to_vec(&evidence).unwrap(), + ) + .unwrap(); + evidence +} + +#[test] +fn finalize_candidate_rolls_back_post_promotion_image_recheck_failure() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let policy = ReleaseImages::from_root(&context.path).unwrap(); + let ubuntu = proof_image_identity(&policy.build_ubuntu); + let fedora = proof_image_identity(&policy.build_fedora); + let images = ResolvedImages { + build_ubuntu: ubuntu.clone(), + build_fedora: fedora.clone(), + proof_debian: proof_image_identity(&policy.proof_debian), + proof_rpm: proof_image_identity(&policy.proof_rpm), + proof_tar: proof_image_identity(&policy.proof_tar), + }; + let deb = finalized_lane_fixture(&staging.deb_lane, Lane::Deb, &context, &ubuntu.digest); + let rpm = finalized_lane_fixture(&staging.rpm_lane, Lane::Rpm, &context, &fedora.digest); + let db = git_repo(); + let descriptor = descriptor(&staging.root, db.path(), None, ¤t_time()); + let counter = tempfile::tempdir().unwrap(); + let count = counter.path().join("inspect-count"); + let podman = format!( + "#!/bin/sh\ncount=0\n[ -f '{0}' ] && count=$(cat '{0}')\ncount=$((count+1)); printf '%s' \"$count\" > '{0}'\nid=${{3##*sha256:}}\n[ \"$count\" -ge 3 ] && id={1}\nprintf '[{{\"Id\":\"sha256:%s\",\"Os\":\"linux\",\"Architecture\":\"amd64\"}}]' \"$id\"\n", + count.display(), + "c".repeat(64) + ); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, Some(&podman)); + let cohort = run_advisory_cohort(&context, &staging, &descriptor, &processes).unwrap(); + let sibling = staging.root.parent().unwrap().join("foreign"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + let canary = tempfile::NamedTempFile::new().unwrap(); + fs::write(canary.path(), b"outside allowlist").unwrap(); + let error = match finalize_candidate(FinalizeInput { + root: &repo.root, + staging: &staging, + context: &context, + version: "1.0.0", + deb: &deb, + rpm: &rpm, + cohort: &cohort, + images: &images, + engine: ContainerEngine::Podman, + engine_identity: "podman version 5.8.3".into(), + processes: &processes, + }) { + Ok(_) => panic!("post-promotion image drift was accepted"), + Err(error) => error, + }; + let error = + finish_candidate_staging::<()>(&repo.root, "1.0.0", &staging.root, Err(error)).unwrap_err(); + assert!(error.to_string().contains("image")); + assert!(!error.to_string().contains("candidate-proven")); + assert!(!staging.root.exists()); + assert!(!repo.root.path().join("dist/rust").exists()); + assert!(!repo.root.path().join("dist/rust-evidence/1.0.0").exists()); + assert_eq!(fs::read(canary.path()).unwrap(), b"outside allowlist"); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); +} + +#[test] +fn finalize_candidate_rolls_back_post_promotion_ledger_write_failure() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let policy = ReleaseImages::from_context(&context).unwrap(); + let images = ResolvedImages { + build_ubuntu: proof_image_identity(&policy.build_ubuntu), + build_fedora: proof_image_identity(&policy.build_fedora), + proof_debian: proof_image_identity(&policy.proof_debian), + proof_rpm: proof_image_identity(&policy.proof_rpm), + proof_tar: proof_image_identity(&policy.proof_tar), + }; + let deb = finalized_lane_fixture( + &staging.deb_lane, + Lane::Deb, + &context, + &images.build_ubuntu.digest, + ); + let rpm = finalized_lane_fixture( + &staging.rpm_lane, + Lane::Rpm, + &context, + &images.build_fedora.digest, + ); + let db = git_repo(); + let descriptor = descriptor(&staging.root, db.path(), None, ¤t_time()); + let image_script = "#!/bin/sh\nid=${3##*sha256:}\nprintf '[{\"Id\":\"sha256:%s\",\"Os\":\"linux\",\"Architecture\":\"amd64\"}]' \"$id\"\n"; + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, Some(image_script)); + let cohort = run_advisory_cohort(&context, &staging, &descriptor, &processes).unwrap(); + let evidence = repo.root.path().join("dist/rust-evidence/1.0.0"); + fs::create_dir_all(&evidence).unwrap(); + fs::set_permissions(&evidence, fs::Permissions::from_mode(0o555)).unwrap(); + let sibling = staging.root.parent().unwrap().join("foreign"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + let result = finalize_candidate(FinalizeInput { + root: &repo.root, + staging: &staging, + context: &context, + version: "1.0.0", + deb: &deb, + rpm: &rpm, + cohort: &cohort, + images: &images, + engine: ContainerEngine::Podman, + engine_identity: "podman version 5.8.3".into(), + processes: &processes, + }); + let error = match result { + Ok(_) => panic!("read-only ledger directory was accepted"), + Err(error) => error, + }; + let error = + finish_candidate_staging::<()>(&repo.root, "1.0.0", &staging.root, Err(error)).unwrap_err(); + assert!(!error.to_string().contains("candidate-proven")); + assert!(!repo.root.path().join("dist/rust").exists()); + assert!(!repo.root.path().join("dist/rust-evidence/1.0.0").exists()); + assert!(!staging.root.exists()); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); +} + +#[test] +fn finalize_candidate_rolls_back_promoted_classification_failure() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let policy = ReleaseImages::from_context(&context).unwrap(); + let images = ResolvedImages { + build_ubuntu: proof_image_identity(&policy.build_ubuntu), + build_fedora: proof_image_identity(&policy.build_fedora), + proof_debian: proof_image_identity(&policy.proof_debian), + proof_rpm: proof_image_identity(&policy.proof_rpm), + proof_tar: proof_image_identity(&policy.proof_tar), + }; + let deb = finalized_lane_fixture( + &staging.deb_lane, + Lane::Deb, + &context, + &images.build_ubuntu.digest, + ); + let rpm = finalized_lane_fixture( + &staging.rpm_lane, + Lane::Rpm, + &context, + &images.build_fedora.digest, + ); + let db = git_repo(); + let descriptor = descriptor(&staging.root, db.path(), None, ¤t_time()); + let counter = tempfile::tempdir().unwrap(); + let count = counter.path().join("count"); + let podman = format!( + "#!/bin/sh\ncount=0\n[ -f '{count}' ] && count=$(cat '{count}')\ncount=$((count+1)); printf '%s' \"$count\" > '{count}'\n[ \"$count\" = 1 ] && printf corrupt >> '{checksum}'\nid=${{3##*sha256:}}\nprintf '[{{\"Id\":\"sha256:%s\",\"Os\":\"linux\",\"Architecture\":\"amd64\"}}]' \"$id\"\n", + count = count.display(), + checksum = staging.payload.join(CHECKSUM_NAME).display(), + ); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, Some(&podman)); + let cohort = run_advisory_cohort(&context, &staging, &descriptor, &processes).unwrap(); + let sibling = staging + .root + .parent() + .unwrap() + .join("classification-foreign"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + let result = finalize_candidate(FinalizeInput { + root: &repo.root, + staging: &staging, + context: &context, + version: "1.0.0", + deb: &deb, + rpm: &rpm, + cohort: &cohort, + images: &images, + engine: ContainerEngine::Podman, + engine_identity: "podman version 5.8.3".into(), + processes: &processes, + }); + let error = match result { + Ok(_) => panic!("promoted checksum corruption was accepted"), + Err(error) => error, + }; + let error = + finish_candidate_staging::<()>(&repo.root, "1.0.0", &staging.root, Err(error)).unwrap_err(); + assert!(!error.to_string().contains("candidate-proven")); + assert!(!repo.root.path().join("dist/rust").exists()); + assert!(!repo.root.path().join("dist/rust-evidence/1.0.0").exists()); + assert!(!staging.root.exists()); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); +} + +#[test] +fn production_finalizer_is_deterministic_for_fixed_lane_bytes_and_evidence() { + let first = fixture(); + let second = fixture(); + assert_eq!(first.commit, second.commit); + let first_lock = CandidateLock::acquire(&first.root).unwrap(); + let second_lock = CandidateLock::acquire(&second.root).unwrap(); + let first_staging = StagingLayout::create(&first.root, &first_lock).unwrap(); + let second_staging = StagingLayout::create(&second.root, &second_lock).unwrap(); + let first_context = export_immutable_context(&first.root, &first_staging.context).unwrap(); + let second_context = export_immutable_context(&second.root, &second_staging.context).unwrap(); + assert_eq!(first_context.commit, second_context.commit); + assert_eq!(first_context.archive_sha256, second_context.archive_sha256); + let policy = ReleaseImages::from_context(&first_context).unwrap(); + let images = ResolvedImages { + build_ubuntu: proof_image_identity(&policy.build_ubuntu), + build_fedora: proof_image_identity(&policy.build_fedora), + proof_debian: proof_image_identity(&policy.proof_debian), + proof_rpm: proof_image_identity(&policy.proof_rpm), + proof_tar: proof_image_identity(&policy.proof_tar), + }; + let templates = tempfile::tempdir().unwrap(); + let deb_template = templates.path().join("deb"); + let rpm_template = templates.path().join("rpm"); + fs::create_dir(&deb_template).unwrap(); + fs::create_dir(&rpm_template).unwrap(); + let mut deb_evidence = finalized_lane_fixture( + &deb_template, + Lane::Deb, + &first_context, + &images.build_ubuntu.digest, + ); + let mut rpm_evidence = finalized_lane_fixture( + &rpm_template, + Lane::Rpm, + &first_context, + &images.build_fedora.digest, + ); + let products = crate::tests::release_fixture(); + let tar = "solstone-linux-1.0.0-linux-x86_64.tar.gz"; + for (output, evidence, package) in [ + ( + &deb_template, + &mut deb_evidence, + "solstone-linux_1.0.0-1_amd64.deb", + ), + ( + &rpm_template, + &mut rpm_evidence, + "solstone-linux-1.0.0-1.x86_64.rpm", + ), + ] { + for name in [tar, package] { + fs::copy(products.path().join(name), output.join(name)).unwrap(); + } + evidence.artifacts = [tar, package] + .iter() + .map(|name| artifact(&output.join(name)).unwrap()) + .collect(); + fs::write( + output.join(LANE_EVIDENCE_NAME), + serde_json::to_vec(evidence).unwrap(), + ) + .unwrap(); + } + let image_script = format!( + r#"#!/bin/sh +if [ "$1" = image ] && [ "$2" = inspect ]; then + id=${{3##*sha256:}} + printf '[{{"Id":"sha256:%s","Os":"linux","Architecture":"amd64"}}]' "$id" + exit 0 +fi +output=; target=; previous= +for argument in "$@"; do + [ "$previous" = --output ] && output=${{argument#type=local,dest=}} + [ "$previous" = --target ] && target=$argument + previous=$argument +done +case "$target" in + deb) source='{deb}'; native='solstone-linux_1.0.0-1_amd64.deb' ;; + rpm) source='{rpm}'; native='solstone-linux-1.0.0-1.x86_64.rpm' ;; + *) exit 91 ;; +esac +/bin/cp "$source/{tar}" "$source/$native" "$output/" +/bin/cp "$source/{evidence}" "$output/{handoff}" +"#, + deb = deb_template.display(), + rpm = rpm_template.display(), + tar = tar, + evidence = LANE_EVIDENCE_NAME, + handoff = LANE_HANDOFF, + ); + let (_bin, processes) = process_bin(CARGO_DENY_ASSERTIONS, Some(&image_script)); + let db = git_repo(); + let descriptor = descriptor(&first_staging.root, db.path(), None, ¤t_time()); + let cohort = + run_advisory_cohort(&first_context, &first_staging, &descriptor, &processes).unwrap(); + + let finalize = |repo: &TestRepo, staging: &StagingLayout, context: &ImmutableContext| { + let deb = build_lane(&lane_request( + repo, + context, + Lane::Deb, + &staging.deb_lane, + &processes, + &images.build_ubuntu, + &images.build_fedora, + )) + .unwrap(); + let rpm = build_lane(&lane_request( + repo, + context, + Lane::Rpm, + &staging.rpm_lane, + &processes, + &images.build_ubuntu, + &images.build_fedora, + )) + .unwrap(); + finalize_candidate(FinalizeInput { + root: &repo.root, + staging, + context, + version: "1.0.0", + deb: &deb, + rpm: &rpm, + cohort: &cohort, + images: &images, + engine: ContainerEngine::Podman, + engine_identity: "podman version 5.8.3".into(), + processes: &processes, + }) + .unwrap() + }; + let first_final = finalize(&first, &first_staging, &first_context); + let second_final = finalize(&second, &second_staging, &second_context); + let inventory = |root: &Path| { + fs::read_dir(root) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + (entry.file_name(), fs::read(entry.path()).unwrap()) + }) + .collect::>() + }; + assert_eq!( + inventory(&first_final.payload_root), + inventory(&second_final.payload_root) + ); + assert_eq!(first_final.ledger_bytes, second_final.ledger_bytes); + assert_eq!( + fs::read(first_final.payload_root.join(CHECKSUM_NAME)).unwrap(), + fs::read(second_final.payload_root.join(CHECKSUM_NAME)).unwrap() + ); + assert_eq!( + fs::read(first_final.payload_root.join(manifest_name("1.0.0"))).unwrap(), + fs::read(second_final.payload_root.join(manifest_name("1.0.0"))).unwrap() + ); +} + +fn lane_handoff( + root: &Path, + lane: Lane, + context: &ImmutableContext, + invocation_id: &str, + image_digest: &str, + tar_bytes: &[u8], + rustc_verbose: &str, +) -> LaneEvidence { + let version = "1.0.0"; + let names = [ + format!("solstone-linux-{version}-linux-x86_64.tar.gz"), + match lane { + Lane::Deb => format!("solstone-linux_{version}-1_amd64.deb"), + Lane::Rpm => format!("solstone-linux-{version}-1.x86_64.rpm"), + }, + ]; + for (name, bytes) in [ + (names[0].clone(), tar_bytes), + (names[1].clone(), b"package".as_slice()), + ] { + fs::write(root.join(name), bytes).unwrap(); + } + let evidence = LaneEvidence { + invocation_id: invocation_id.into(), + lane, + source_commit: context.commit.clone(), + source_archive_sha256: context.archive_sha256.clone(), + cargo_lock_sha256: context.cargo_lock_sha256.clone(), + version: version.into(), + target: TARGET_TRIPLE.into(), + profile: "release".into(), + features: Vec::new(), + rustc_verbose: rustc_verbose.trim_end().into(), + cargo: "cargo 1.97.1 (abcdef012 2026-06-30)".into(), + baseline_executable_sha256: "d".repeat(64), + image_digest: image_digest.into(), + packaging_tool: match lane { + Lane::Deb => "cargo-deb 3.7.0\n", + Lane::Rpm => "cargo-generate-rpm 0.21.0\n", + } + .trim() + .into(), + native_tools: lane_tools(lane, image_digest), + artifacts: names + .iter() + .map(|name| artifact(&root.join(name)).unwrap()) + .collect(), + }; + fs::write( + root.join(LANE_HANDOFF), + serde_json::to_vec(&evidence).unwrap(), + ) + .unwrap(); + evidence +} + +fn lane_request<'a>( + repo: &'a TestRepo, + context: &'a ImmutableContext, + lane: Lane, + output: &'a Path, + processes: &'a ProcessEnvironment, + ubuntu: &'a ImageIdentity, + fedora: &'a ImageIdentity, +) -> LaneRequest<'a> { + LaneRequest { + repo: &repo.root, + context, + lane, + engine: ContainerEngine::Podman, + invocation_id: "0123456789abcdef0123456789abcdef", + version: "1.0.0", + ubuntu, + fedora, + output, + processes, + } +} + +#[test] +fn lane_build_argv_is_offline_no_pull_and_uses_exported_context() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + lane_handoff( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"same tar", + "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0\n", + ); + fs::write( + repo.root.path().join("packaging/Containerfile"), + b"LIVE CONTAINERFILE MUTATION", + ) + .unwrap(); + let record = staging.root.join("podman-argv"); + let script = format!( + r#"#!/bin/sh +printf '%s\0' "$@" > '{}' +pull=0 +network=0 +last= +file= +next_file=0 +for arg in "$@"; do + [ "$arg" = '--pull=never' ] && pull=1 + [ "$arg" = '--network=none' ] && network=1 + if [ "$next_file" -eq 1 ]; then file="$arg"; next_file=0; fi + [ "$arg" = '--file' ] && next_file=1 + last="$arg" +done +[ "$pull" -eq 1 ] && [ "$network" -eq 1 ] || exit 90 +[ "$last" = '{}' ] || exit 91 +[ "$file" = '{}/packaging/Containerfile' ] || exit 92 +grep -F 'LIVE CONTAINERFILE MUTATION' "$file" >/dev/null && exit 93 +exit 0 +"#, + record.display(), + context.path.display(), + context.path.display() + ); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&script)); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + build_lane(&request).unwrap(); + let argv = fs::read(record).unwrap(); + assert!( + argv.windows(b"--pull=never".len()) + .any(|window| window == b"--pull=never") + ); + assert!( + argv.windows(b"--network=none".len()) + .any(|window| window == b"--network=none") + ); + for binding in [ + "INVOCATION_ID=0123456789abcdef0123456789abcdef".to_owned(), + format!("SOURCE_COMMIT={}", context.commit), + format!("SOURCE_ARCHIVE_SHA256={}", context.archive_sha256), + format!("CARGO_LOCK_SHA256={}", context.cargo_lock_sha256), + format!("UBUNTU_TOOL_BASE={}", ubuntu.digest), + "RELEASE_VERSION=1.0.0".to_owned(), + ] { + assert!( + argv.windows(binding.len()) + .any(|window| window == binding.as_bytes()), + "missing build binding {binding}" + ); + } + let containerfile = fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../packaging/Containerfile"), + ) + .unwrap(); + for consumed in [ + "--invocation-id \"$INVOCATION_ID\"", + "--source-commit \"$SOURCE_COMMIT\"", + "--source-archive-sha256 \"$SOURCE_ARCHIVE_SHA256\"", + "--cargo-lock-sha256 \"$CARGO_LOCK_SHA256\"", + "--image-digest \"$UBUNTU_TOOL_BASE\"", + ] { + assert!(containerfile.contains(consumed), "unconsumed {consumed}"); + } + + let mut live = context.clone(); + live.path = repo.root.path().to_owned(); + let tripwire = staging.root.join("tripwire"); + let script = format!( + "#!/bin/sh\nprintf called > '{}'\nexit 0\n", + tripwire.display() + ); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&script)); + let request = lane_request( + &repo, + &live, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!(build_lane(&request).is_err()); + assert!(!tripwire.exists()); +} + +#[test] +fn lane_build_rejects_extra_output_and_unsafe_rustc_evidence() { + for case in ["extra", "secret", "control"] { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let rustc = match case { + "secret" => { + "rustc 1.97.1 (secret 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n" + } + "control" => { + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\0" + } + _ => { + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n" + } + }; + lane_handoff( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"tar", + rustc, + ); + if case == "extra" { + fs::write(staging.deb_lane.join("unexpected-output"), b"canary").unwrap(); + } + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!(build_lane(&request).is_err(), "accepted {case}"); + } +} + +#[test] +fn lane_build_rejects_malformed_image_digests_before_subprocess() { + for digest in [ + "sha256:abcd".to_owned(), + format!("sha256:{}", "A".repeat(64)), + "not-a-digest".to_owned(), + ] { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest, + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let tripwire = staging.root.join("container-tripwire"); + let script = format!( + "#!/bin/sh\nprintf called > '{}'\nexit 0\n", + tripwire.display() + ); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&script)); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!(build_lane(&request).is_err()); + assert!(!tripwire.exists()); + assert!(fs::read_dir(&staging.deb_lane).unwrap().next().is_none()); + } +} + +#[test] +fn production_handoff_rejects_missing_duplicate_unknown_and_byte_drift() { + let fields = [ + "invocation_id", + "lane", + "source_commit", + "source_archive_sha256", + "cargo_lock_sha256", + "version", + "target", + "profile", + "features", + "rustc_verbose", + "cargo", + "baseline_executable_sha256", + "image_digest", + "packaging_tool", + "artifacts", + ]; + for mutation in fields + .iter() + .map(|field| format!("missing:{field}")) + .chain([ + "duplicate:key".into(), + "duplicate:artifact".into(), + "unknown:key".into(), + "artifact:bytes".into(), + ]) + { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let evidence = lane_handoff( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"tar", + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n", + ); + let handoff = staging.deb_lane.join(LANE_HANDOFF); + if let Some(field) = mutation.strip_prefix("missing:") { + let mut value = serde_json::to_value(&evidence).unwrap(); + value.as_object_mut().unwrap().remove(field); + fs::write(&handoff, serde_json::to_vec(&value).unwrap()).unwrap(); + } else if mutation == "duplicate:key" { + let original = fs::read_to_string(&handoff).unwrap(); + fs::write( + &handoff, + format!( + "{{\"invocation_id\":\"ffffffffffffffffffffffffffffffff\",{}", + &original[1..] + ), + ) + .unwrap(); + } else if mutation == "duplicate:artifact" { + let mut value = serde_json::to_value(&evidence).unwrap(); + let duplicate = value["artifacts"][0].clone(); + value["artifacts"].as_array_mut().unwrap().push(duplicate); + fs::write(&handoff, serde_json::to_vec(&value).unwrap()).unwrap(); + } else if mutation == "unknown:key" { + let mut value = serde_json::to_value(&evidence).unwrap(); + value["unallowlisted"] = Value::Bool(true); + fs::write(&handoff, serde_json::to_vec(&value).unwrap()).unwrap(); + } else { + fs::write( + staging + .deb_lane + .join("solstone-linux-1.0.0-linux-x86_64.tar.gz"), + b"changed", + ) + .unwrap(); + } + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!(build_lane(&request).is_err(), "accepted {mutation}"); + } +} + +#[test] +fn production_handoff_rejects_stale_swapped_and_crosswired_documents() { + for mutation in ["invocation_id", "image_digest", "lane"] { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let evidence = lane_handoff( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"tar", + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n", + ); + let mut value = serde_json::to_value(evidence).unwrap(); + value[mutation] = match mutation { + "invocation_id" => Value::String("fedcba9876543210fedcba9876543210".into()), + "image_digest" => Value::String(format!("sha256:{}", "b".repeat(64))), + "lane" => Value::String("rpm".into()), + _ => unreachable!(), + }; + fs::write( + staging.deb_lane.join(LANE_HANDOFF), + serde_json::to_vec(&value).unwrap(), + ) + .unwrap(); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!(build_lane(&request).is_err(), "accepted {mutation}"); + } + + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let rustc = + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n"; + lane_handoff( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"tar", + rustc, + ); + lane_handoff( + &staging.rpm_lane, + Lane::Rpm, + &context, + "0123456789abcdef0123456789abcdef", + &fedora.digest, + b"tar", + rustc, + ); + let deb_document = fs::read(staging.deb_lane.join(LANE_HANDOFF)).unwrap(); + let rpm_document = fs::read(staging.rpm_lane.join(LANE_HANDOFF)).unwrap(); + fs::write(staging.deb_lane.join(LANE_HANDOFF), rpm_document).unwrap(); + fs::write(staging.rpm_lane.join(LANE_HANDOFF), deb_document).unwrap(); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + for (lane, output) in [ + (Lane::Deb, staging.deb_lane.as_path()), + (Lane::Rpm, staging.rpm_lane.as_path()), + ] { + let request = lane_request(&repo, &context, lane, output, &processes, &ubuntu, &fedora); + assert!( + build_lane(&request).is_err(), + "accepted cross-wired {lane:?}" + ); + } +} + +#[test] +fn production_handoff_rejects_every_native_identity_mutation() { + for (lane, keys) in [ + ( + Lane::Deb, + &[ + "cargo_deb", + "dpkg_deb", + "signing_mode", + "ubuntu_cargo", + "ubuntu_compiler", + "ubuntu_glibc", + "ubuntu_gzip", + "ubuntu_image_digest", + "ubuntu_linker", + "ubuntu_os", + "ubuntu_rustc", + "ubuntu_tar", + ][..], + ), + ( + Lane::Rpm, + &[ + "cargo_generate_rpm", + "fedora_image_digest", + "fedora_os", + "rpm", + "signing_mode", + ][..], + ), + ] { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let output = match lane { + Lane::Deb => &staging.deb_lane, + Lane::Rpm => &staging.rpm_lane, + }; + let image = match lane { + Lane::Deb => &ubuntu.digest, + Lane::Rpm => &fedora.digest, + }; + let evidence = lane_handoff( + output, + lane, + &context, + "0123456789abcdef0123456789abcdef", + image, + b"tar", + "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\n", + ); + let base = serde_json::to_value(evidence).unwrap(); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + let request = lane_request(&repo, &context, lane, output, &processes, &ubuntu, &fedora); + for key in keys { + for mutation in ["missing", "duplicate", "unknown", "wrong"] { + let mut value = base.clone(); + let bytes = match mutation { + "missing" => { + value["native_tools"].as_object_mut().unwrap().remove(*key); + serde_json::to_vec(&value).unwrap() + } + "duplicate" => { + let text = serde_json::to_string(&value).unwrap(); + text.replacen( + "\"native_tools\":{", + &format!("\"native_tools\":{{\"{key}\":\"duplicate\","), + 1, + ) + .into_bytes() + } + "unknown" => { + value["native_tools"]["unallowlisted_identity"] = + Value::String("tool 1.0".into()); + serde_json::to_vec(&value).unwrap() + } + "wrong" => { + value["native_tools"][*key] = Value::String(if key.ends_with("_digest") { + "c".repeat(64) + } else { + "wrong-tool 1.0".into() + }); + serde_json::to_vec(&value).unwrap() + } + _ => unreachable!(), + }; + fs::write(output.join(LANE_HANDOFF), bytes).unwrap(); + assert!( + build_lane(&request).is_err(), + "accepted {lane:?} {key} {mutation}" + ); + } + } + } +} + +#[test] +fn lane_evidence_rejects_stale_swapped_crosswired_and_tar_mismatch() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let ubuntu = ImageIdentity { + configured_tag: "ubuntu:tool".into(), + digest: format!("sha256:{}", "a".repeat(64)), + }; + let fedora = ImageIdentity { + configured_tag: "fedora:tool".into(), + digest: format!("sha256:{}", "b".repeat(64)), + }; + let invocation = "0123456789abcdef0123456789abcdef"; + let deb = lane_fixture( + &staging.deb_lane, + Lane::Deb, + &context, + invocation, + &ubuntu.digest, + b"deb tar", + ); + let rpm = lane_fixture( + &staging.rpm_lane, + Lane::Rpm, + &context, + invocation, + &fedora.digest, + b"rpm tar", + ); + let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 0\n")); + + for field in [ + "invocation_id", + "source_commit", + "source_archive_sha256", + "cargo_lock_sha256", + "image_digest", + "lane", + ] { + let mut value = serde_json::to_value(&deb).unwrap(); + value[field] = match field { + "lane" => Value::String("rpm".into()), + _ => Value::String("f".repeat(64)), + }; + let mutated: LaneEvidence = serde_json::from_value(value).unwrap(); + let request = lane_request( + &repo, + &context, + Lane::Deb, + &staging.deb_lane, + &processes, + &ubuntu, + &fedora, + ); + assert!( + validate_lane_evidence(&mutated, &request).is_err(), + "accepted {field}" + ); + } + fs::write( + staging.deb_lane.join(LANE_EVIDENCE_NAME), + serde_json::to_vec(&deb).unwrap(), + ) + .unwrap(); + assert!(reconcile_lanes(&deb, &rpm, &staging.deb_lane, &staging.rpm_lane).is_err()); +} + +#[test] +fn manifest_tool_map_is_derived_from_two_lanes_and_host_identity() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + let deb = lane_fixture( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &format!("sha256:{}", "a".repeat(64)), + b"tar", + ); + let rpm = lane_fixture( + &staging.rpm_lane, + Lane::Rpm, + &context, + "0123456789abcdef0123456789abcdef", + &format!("sha256:{}", "b".repeat(64)), + b"tar", + ); + let tools = + assemble_manifest_native_tools(&repo.root, &deb, &rpm, "podman version 5.8.3".into()) + .unwrap(); + assert_eq!(tools.len(), 18); + assert_eq!(tools["container_engine"], "podman version 5.8.3"); + assert_eq!(tools["manifest_validator"], env!("CARGO_PKG_VERSION")); + assert_eq!(tools["ubuntu_image_digest"], "a".repeat(64)); + assert_eq!(tools["fedora_image_digest"], "b".repeat(64)); +} diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index 6645a5b..1f6fd49 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -11,15 +11,20 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Cursor, Read}; use std::net::IpAddr; -use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt, symlink}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use tar::{Archive, Entry, EntryType}; use xz2::read::XzDecoder; +mod candidate; +pub use candidate::*; +mod transaction; +pub use transaction::*; + pub const SCHEMA_VERSION: u64 = 1; pub const SCHEMA_SHA256: &str = "d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c"; pub const CHECKSUM_NAME: &str = "SHA256SUMS"; @@ -30,6 +35,10 @@ pub const MANIFEST_OK_MESSAGE: &str = "Named manifest and artifacts verified; this is NOT candidate-readiness classification."; pub const RELEASE_DIR_OK_MESSAGE: &str = "Release directory verified as a complete five-file candidate."; +pub const LEDGER_SCHEMA_SHA256: &str = + "c93e189b2e7bc1c65d38f52f924c74a101a4b3f39acbe73ba626b4f59e180533"; +pub const PROOF_SCHEMA_SHA256: &str = + "3009eab983eea832961220406f19c7459ed1db7fffc352af6ffaf664f9cd7dcf"; pub fn manifest_name(version: &str) -> String { format!("solstone-linux-{version}-linux-x86_64.rust-release-manifest.json") @@ -37,6 +46,23 @@ pub fn manifest_name(version: &str) -> String { const SCHEMA_BYTES: &[u8] = include_bytes!("../../../vendor/rust-release-manifest/rust-release-manifest.schema.json"); +const LEDGER_SCHEMA_BYTES: &[u8] = include_bytes!( + "../../../vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json" +); +const PROOF_SCHEMA_BYTES: &[u8] = include_bytes!( + "../../../vendor/rust-release-candidate-proof/rust-release-candidate-proof.schema.json" +); +const EXPECTED_LAYOUT: [&str; 9] = [ + "Cargo.toml", + "Cargo.lock", + "deny.toml", + "Makefile", + "rust-toolchain.toml", + "packaging/Containerfile", + "packaging/release-policy.toml", + "crates/solstone-linux/Cargo.toml", + "crates/rust-release-manifest/Cargo.toml", +]; const TOOL_KEYS: [&str; 18] = [ "cargo_deb", "cargo_generate_rpm", @@ -79,8 +105,200 @@ impl std::error::Error for Error {} type Result = std::result::Result; +#[derive(Clone, Debug)] +pub struct RepoRoot(PathBuf); + +impl RepoRoot { + pub fn resolve() -> Result { + let cwd = std::env::current_dir().map_err(display_error)?; + let value = command(&cwd, &["git", "rev-parse", "--show-toplevel"]).map_err(|error| { + Error::new(format!( + "repository root mismatch: expected solstone-linux Git checkout, actual {error}\nrepair: run from the expected solstone-linux checkout" + )) + })?; + Self::validate_path(Path::new(&value)).map_err(|error| { + Error::new(format!( + "{error}\nrepair: run from the expected solstone-linux checkout" + )) + }) + } + + fn validate_path(path: &Path) -> Result { + let root = path.canonicalize().map_err(display_error)?; + for relative in EXPECTED_LAYOUT { + require_regular(&root.join(relative), relative)?; + } + let workspace: toml::Value = + toml::from_str(&fs::read_to_string(root.join("Cargo.toml")).map_err(display_error)?) + .map_err(display_error)?; + let members = workspace["workspace"]["members"] + .as_array() + .ok_or_else(|| { + Error::new("workspace layout mismatch: expected members, actual missing") + })? + .iter() + .filter_map(toml::Value::as_str) + .collect::>(); + let expected = BTreeSet::from(["crates/solstone-linux", "crates/rust-release-manifest"]); + if !expected.is_subset(&members) { + return Err(Error::new( + "workspace layout mismatch: expected release workspace members, actual incomplete", + )); + } + Ok(Self(root)) + } + + pub fn path(&self) -> &Path { + &self.0 + } +} + +#[derive(Debug)] +pub struct CandidateLock { + path: PathBuf, + file: File, +} + +impl CandidateLock { + pub fn acquire(root: &RepoRoot) -> Result { + let dist = root.path().join("dist"); + fs::create_dir_all(&dist).map_err(display_error)?; + let path = dist.join(".rust-release-candidate.lock"); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .map_err(|error| { + Error::new(format!( + "release candidate lock mismatch: expected exclusive owner, actual {error}" + )) + })?; + Ok(Self { path, file }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for CandidateLock { + fn drop(&mut self) { + let owned = self.file.metadata(); + let same_file = fs::symlink_metadata(&self.path).is_ok_and(|metadata| { + owned + .as_ref() + .is_ok_and(|owned| metadata.dev() == owned.dev() && metadata.ino() == owned.ino()) + }); + if same_file { + let _ = fs::remove_file(&self.path); + } + } +} + +#[derive(Debug)] +pub struct StagingLayout { + pub root: PathBuf, + pub context: PathBuf, + pub deb_lane: PathBuf, + pub rpm_lane: PathBuf, + pub advisory_db: PathBuf, + pub payload: PathBuf, + pub proofs: PathBuf, + pub ledger_temp: PathBuf, +} + +impl StagingLayout { + pub fn create(root: &RepoRoot, _lock: &CandidateLock) -> Result { + let transaction_id = transaction_id()?; + let staging = root + .path() + .join("dist/.rust-release-candidate-staging") + .join(transaction_id); + Self::create_owned(staging) + } + + pub(crate) fn create_owned(staging: PathBuf) -> Result { + let parent = staging + .parent() + .ok_or_else(|| Error::new("candidate staging parent mismatch"))?; + fs::create_dir_all(parent).map_err(display_error)?; + fs::create_dir(&staging).map_err(display_error)?; + Self::initialize_owned(staging) + } + + pub(crate) fn initialize_owned(staging: PathBuf) -> Result { + let layout = Self { + context: staging.join("context"), + deb_lane: staging.join("lane-deb"), + rpm_lane: staging.join("lane-rpm"), + advisory_db: staging.join("advisory-db"), + payload: staging.join("payload"), + proofs: staging.join("proofs"), + ledger_temp: staging.join("ledger.json.tmp"), + root: staging.clone(), + }; + let setup = (|| { + for directory in [ + &layout.context, + &layout.deb_lane, + &layout.rpm_lane, + &layout.advisory_db, + &layout.payload, + &layout.proofs, + ] { + fs::create_dir_all(directory).map_err(display_error)?; + } + Ok(layout) + })(); + match setup { + Ok(layout) => Ok(layout), + Err(primary) => match fs::remove_dir_all(&staging) { + Ok(()) => Err(primary), + Err(cleanup) => Err(Error::new(format!( + "{primary}\nerror: candidate staging setup cleanup mismatch: expected owned root absent, actual residue\nrepair: remove {} after confirming no release-candidate process holds dist/.rust-release-candidate.lock: {cleanup}", + staging.display() + ))), + }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImmutableContext { + pub commit: String, + pub archive_sha256: String, + pub cargo_lock_sha256: String, + pub path: PathBuf, +} + +pub fn owner_emptying_allowlist( + root: &RepoRoot, + version: &str, + transaction_id: &str, +) -> Result> { + validate_version(version)?; + if transaction_id.len() != 32 + || !transaction_id + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(Error::new( + "transaction ID mismatch: expected 32 lowercase hexadecimal characters, actual invalid", + )); + } + let dist = root.path().join("dist"); + Ok(BTreeSet::from([ + dist.join("rust"), + dist.join("rust-evidence").join(version), + dist.join("rust-drift"), + dist.join(".rust-release-candidate-staging") + .join(transaction_id), + ])) +} + #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Evidence { +pub(crate) struct Evidence { pub schema_version: u64, pub product: String, pub version: String, @@ -149,8 +367,22 @@ struct PackageIdentity { arch: String, } -pub fn render_manifest(evidence: Evidence, release_dir: &Path) -> Result { - validate_evidence(&evidence)?; +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct PackageMemberEvidence { + pub package_file: String, + pub format: String, + pub installed_path: String, + pub mode: u64, + pub bytes: u64, + pub sha256: String, +} + +pub(crate) fn render_manifest( + root: &RepoRoot, + evidence: Evidence, + release_dir: &Path, +) -> Result { + validate_evidence(root, &evidence)?; validate_version(&evidence.version)?; let artifacts = artifact_paths(release_dir, &evidence.version)? .into_iter() @@ -172,7 +404,7 @@ pub fn render_manifest(evidence: Evidence, release_dir: &Path) -> Result }; let mut output = serde_json::to_string_pretty(&manifest).map_err(display_error)?; output.push('\n'); - validate_manifest_bytes(output.as_bytes())?; + validate_manifest_bytes(root, output.as_bytes())?; Ok(output) } @@ -186,7 +418,7 @@ pub fn render_sha256sums(artifacts: &[Artifact]) -> Result { .collect()) } -pub fn validate_manifest_bytes(bytes: &[u8]) -> Result { +pub fn validate_manifest_bytes(root: &RepoRoot, bytes: &[u8]) -> Result { verify_schema()?; let value: Value = serde_json::from_slice(bytes).map_err(display_error)?; let schema: Value = serde_json::from_slice(SCHEMA_BYTES).map_err(display_error)?; @@ -198,20 +430,20 @@ pub fn validate_manifest_bytes(bytes: &[u8]) -> Result { return Err(Error::new(format!("manifest schema mismatch: {error}"))); } let manifest: Manifest = serde_json::from_value(value).map_err(display_error)?; - validate_manifest_policy(&manifest)?; + validate_manifest_policy(root, &manifest)?; Ok(manifest) } -pub fn verify_manifest_mode(path: &Path) -> Result<()> { - verify_manifest(path, true) +pub fn verify_manifest_mode(repo: &RepoRoot, path: &Path) -> Result<()> { + verify_manifest(repo, path, true) } -fn verify_manifest(path: &Path, bind_live: bool) -> Result<()> { +fn verify_manifest(repo: &RepoRoot, path: &Path, bind_live: bool) -> Result<()> { require_regular(path, "manifest")?; let root = path .parent() .ok_or_else(|| Error::new("manifest parent missing"))?; - let manifest = validate_manifest_bytes(&fs::read(path).map_err(display_error)?)?; + let manifest = validate_manifest_bytes(repo, &fs::read(path).map_err(display_error)?)?; let expected_name = manifest_name(&manifest.version); if path.file_name().and_then(OsStr::to_str) != Some(expected_name.as_str()) { return Err(Error::new(format!( @@ -219,17 +451,17 @@ fn verify_manifest(path: &Path, bind_live: bool) -> Result<()> { ))); } if bind_live { - validate_live(&manifest, root)?; + validate_live(repo, &manifest, root)?; } verify_artifacts(&manifest, root)?; verify_checksums(&manifest, root) } -pub fn classify_release_dir(root: &Path) -> Result<()> { - classify_release(root, true) +pub fn classify_release_dir(repo: &RepoRoot, root: &Path) -> Result<()> { + classify_release(repo, root, true) } -fn classify_release(root: &Path, bind_live: bool) -> Result<()> { +fn classify_release(repo: &RepoRoot, root: &Path, bind_live: bool) -> Result<()> { require_directory(root, "release root")?; let mut names = BTreeSet::new(); for entry in fs::read_dir(root).map_err(display_error)? { @@ -255,46 +487,398 @@ fn classify_release(root: &Path, bind_live: bool) -> Result<()> { ))); } let manifest_path = root.join(manifests[0]); - verify_manifest(&manifest_path, bind_live) + verify_manifest(repo, &manifest_path, bind_live) +} + +pub fn schema_bytes() -> &'static [u8] { + SCHEMA_BYTES +} + +pub fn ledger_schema_bytes() -> &'static [u8] { + LEDGER_SCHEMA_BYTES +} + +pub fn proof_schema_bytes() -> &'static [u8] { + PROOF_SCHEMA_BYTES } -pub fn write_rendered(evidence: Evidence, release_dir: &Path) -> Result<()> { - let manifest_text = render_manifest(evidence, release_dir)?; - let manifest = validate_manifest_bytes(manifest_text.as_bytes())?; - let sums = render_sha256sums(&manifest.artifacts)?; - fs::write( - release_dir.join(manifest_name(&manifest.version)), - manifest_text, +pub fn verify_candidate_schemas() -> Result<()> { + verify_pinned_schema( + LEDGER_SCHEMA_BYTES, + LEDGER_SCHEMA_SHA256, + "https://solpbc.org/schemas/rust-release-candidate-ledger/v1.json", + "ledger", + )?; + verify_pinned_schema( + PROOF_SCHEMA_BYTES, + PROOF_SCHEMA_SHA256, + "https://solpbc.org/schemas/rust-release-candidate-proof/v1.json", + "proof", ) - .map_err(display_error)?; - fs::write(release_dir.join(CHECKSUM_NAME), sums).map_err(display_error) } -pub fn schema_bytes() -> &'static [u8] { - SCHEMA_BYTES +pub fn canonical_json(value: &Value) -> Result> { + fn normalize(value: &Value, field: Option<&str>) -> Result { + match value { + Value::Object(object) => Ok(Value::Object( + object + .iter() + .map(|(key, value)| Ok((key.clone(), normalize(value, Some(key))?))) + .collect::>()?, + )), + Value::Array(array) => { + let mut normalized = array + .iter() + .map(|value| normalize(value, None)) + .collect::>>()?; + match field { + Some("features" | "active_exceptions" | "expected_proof_ids") => { + normalized.sort_by(|left, right| left.as_str().cmp(&right.as_str())); + } + Some("payload") => normalized.sort_by(|left, right| { + left.get("path") + .and_then(Value::as_str) + .cmp(&right.get("path").and_then(Value::as_str)) + }), + Some("package_members") => normalized.sort_by(|left, right| { + left.get("package_file") + .and_then(Value::as_str) + .cmp(&right.get("package_file").and_then(Value::as_str)) + }), + _ => {} + } + Ok(Value::Array(normalized)) + } + _ => Ok(value.clone()), + } + } + + let normalized = normalize(value, None)?; + let mut bytes = serde_json::to_vec(&normalized).map_err(display_error)?; + bytes.push(b'\n'); + Ok(bytes) +} + +pub fn candidate_digest_input(payload: &[Artifact]) -> Result> { + if payload.len() != 5 { + return Err(Error::new(format!( + "candidate payload mismatch: expected 5 files, actual {}", + payload.len() + ))); + } + let mut payload = payload.to_vec(); + payload.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes())); + let mut previous: Option<&str> = None; + let mut output = Vec::new(); + for file in &payload { + portable_path(&file.path)?; + if Path::new(&file.path).file_name() != Some(OsStr::new(&file.path)) { + return Err(Error::new( + "candidate payload path mismatch: expected basename, actual path", + )); + } + if previous == Some(&file.path) { + return Err(Error::new( + "candidate payload path mismatch: expected unique, actual duplicate", + )); + } + if !is_sha256(&file.sha256) || file.bytes == 0 { + return Err(Error::new(format!( + "candidate payload metadata mismatch: {}", + file.path + ))); + } + previous = Some(&file.path); + output.extend_from_slice( + format!("{} {} {}\n", file.sha256, file.bytes, file.path).as_bytes(), + ); + } + Ok(output) +} + +pub fn candidate_digest(payload: &[Artifact]) -> Result { + Ok(digest(&candidate_digest_input(payload)?)) } -fn validate_evidence(evidence: &Evidence) -> Result<()> { +#[derive(Serialize)] +struct BundleDigestInput<'a> { + candidate_digest: &'a str, + ledger_sha256: String, + proofs: BTreeMap<&'a str, String>, +} + +pub fn bundle_digest_input( + candidate: &str, + ledger: &[u8], + proofs: &BTreeMap>, +) -> Result> { + if !is_sha256(candidate) { + return Err(Error::new( + "candidate digest mismatch: expected sha256, actual invalid", + )); + } + let expected = BTreeSet::from(["debian-amd64", "rpm-x86_64", "tar-x86_64"]); + let actual = proofs.keys().map(String::as_str).collect::>(); + if actual != expected { + return Err(Error::new(format!( + "proof inventory mismatch: expected 3 proof IDs, actual {}", + actual.len() + ))); + } + let input = BundleDigestInput { + candidate_digest: candidate, + ledger_sha256: digest(ledger), + proofs: proofs + .iter() + .map(|(id, bytes)| (id.as_str(), digest(bytes))) + .collect(), + }; + serde_json::to_vec(&input).map_err(display_error) +} + +pub fn bundle_digest( + candidate: &str, + ledger: &[u8], + proofs: &BTreeMap>, +) -> Result { + Ok(digest(&bundle_digest_input(candidate, ledger, proofs)?)) +} + +#[derive(Clone, Debug)] +pub struct ProofBindings { + pub platform: String, + pub candidate_digest: String, + pub ledger_sha256: String, + pub source_commit: String, + pub cargo_lock_sha256: String, + pub artifact_basename: String, + pub artifact_bytes: u64, + pub artifact_sha256: String, + pub proof_image_digest: String, + pub os_release: String, + pub package_manager_version: String, + pub install_command: Vec, + pub install_exit_status: i64, + pub version_command: Vec, + pub version_exit_status: i64, + pub executable_path: String, + pub executable_mode: u64, + pub executable_sha256: String, + pub version_output: String, + pub result: String, + pub policy_checked_at: String, + pub validation_time: String, +} + +pub fn validate_candidate_proof(value: &Value, expected: &ProofBindings) -> Result<()> { + verify_candidate_schemas()?; + let schema: Value = serde_json::from_slice(PROOF_SCHEMA_BYTES).map_err(display_error)?; + let validator = jsonschema::draft202012::options() + .should_validate_formats(true) + .build(&schema) + .map_err(display_error)?; + if let Err(error) = validator.validate(value) { + return Err(Error::new(format!("proof schema mismatch: {error}"))); + } + let object = value + .as_object() + .ok_or_else(|| Error::new("proof mismatch: expected object, actual other"))?; + let expected_fields = [ + ("platform", Value::String(expected.platform.clone())), + ( + "candidate_digest", + Value::String(expected.candidate_digest.clone()), + ), + ( + "ledger_sha256", + Value::String(expected.ledger_sha256.clone()), + ), + ( + "source_commit", + Value::String(expected.source_commit.clone()), + ), + ( + "cargo_lock_sha256", + Value::String(expected.cargo_lock_sha256.clone()), + ), + ( + "artifact_basename", + Value::String(expected.artifact_basename.clone()), + ), + ("artifact_bytes", Value::from(expected.artifact_bytes)), + ( + "artifact_sha256", + Value::String(expected.artifact_sha256.clone()), + ), + ( + "proof_image_digest", + Value::String(expected.proof_image_digest.clone()), + ), + ("os_release", Value::String(expected.os_release.clone())), + ( + "package_manager_version", + Value::String(expected.package_manager_version.clone()), + ), + ( + "install_command", + serde_json::to_value(&expected.install_command).map_err(display_error)?, + ), + ( + "install_exit_status", + Value::from(expected.install_exit_status), + ), + ( + "version_command", + serde_json::to_value(&expected.version_command).map_err(display_error)?, + ), + ( + "version_exit_status", + Value::from(expected.version_exit_status), + ), + ( + "executable_path", + Value::String(expected.executable_path.clone()), + ), + ("executable_mode", Value::from(expected.executable_mode)), + ( + "executable_sha256", + Value::String(expected.executable_sha256.clone()), + ), + ( + "version_output", + Value::String(expected.version_output.clone()), + ), + ("result", Value::String(expected.result.clone())), + ]; + for (field, expected_value) in expected_fields { + if object.get(field) != Some(&expected_value) { + return Err(Error::new(format!("proof {field} mismatch"))); + } + } + let os_release = object["os_release"] + .as_str() + .ok_or_else(|| Error::new("proof os_release mismatch"))?; + validate_identity( + if expected.platform == "rpm-x86_64" { + "fedora_os" + } else { + "ubuntu_os" + }, + os_release, + )?; + let manager = object["package_manager_version"] + .as_str() + .ok_or_else(|| Error::new("proof package_manager_version mismatch"))?; + let manager_ok = match expected.platform.as_str() { + "debian-amd64" => { + manager.starts_with("Debian 'dpkg' package management program version ") + || manager.starts_with("dpkg ") + } + "rpm-x86_64" => manager.starts_with("RPM version ") || manager.starts_with("rpm "), + "tar-x86_64" => manager == "installer portable-tar", + _ => false, + }; + if !manager_ok { + return Err(Error::new( + "proof package manager mismatch: expected platform policy, actual different", + )); + } + let proof_time = object["proof_time"] + .as_str() + .ok_or_else(|| Error::new("proof proof_time mismatch"))?; + validate_timestamp(proof_time)?; + validate_timestamp(&expected.policy_checked_at)?; + validate_timestamp(&expected.validation_time)?; + let proof_time = DateTime::parse_from_rfc3339(proof_time).map_err(display_error)?; + let checked_at = + DateTime::parse_from_rfc3339(&expected.policy_checked_at).map_err(display_error)?; + let validation_time = + DateTime::parse_from_rfc3339(&expected.validation_time).map_err(display_error)?; + if proof_time < checked_at || proof_time > validation_time { + return Err(Error::new( + "proof proof_time mismatch: expected advisory window, actual outside", + )); + } + Ok(()) +} + +pub fn advisory_db_directory(url: &str) -> Result { + let lower = url.to_ascii_lowercase(); + if !lower.starts_with("file://") { + return Err(Error::new( + "advisory database URL mismatch: expected file URL, actual other", + )); + } + let name = lower + .split('/') + .next_back() + .filter(|value| !value.is_empty()) + .unwrap_or("empty_"); + portable_path_component(name)?; + Ok(format!( + "{name}-{:016x}", + xxh64(0xca80_de71, lower.as_bytes()) + )) +} + +pub fn export_immutable_context(root: &RepoRoot, destination: &Path) -> Result { + require_directory(destination, "immutable context destination")?; + if fs::read_dir(destination) + .map_err(display_error)? + .next() + .is_some() + { + return Err(Error::new( + "immutable context mismatch: expected empty destination, actual populated", + )); + } + let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; + if !is_git_commit(&commit) { + return Err(Error::new( + "release commit mismatch: expected 40 or 64 lowercase hexadecimal characters, actual invalid", + )); + } + let archive = command_bytes(root.path(), &["git", "archive", "--format=tar", "HEAD"])?; + extract_context(&archive, destination, &commit)?; + let source_lock = digest(&fs::read(root.path().join("Cargo.lock")).map_err(display_error)?); + let extracted_lock = digest(&fs::read(destination.join("Cargo.lock")).map_err(display_error)?); + if source_lock != extracted_lock { + return Err(Error::new(format!( + "Cargo.lock digest mismatch: expected {source_lock}, actual {extracted_lock}" + ))); + } + for relative in EXPECTED_LAYOUT { + require_regular(&destination.join(relative), relative)?; + } + Ok(ImmutableContext { + commit, + archive_sha256: digest(&archive), + cargo_lock_sha256: source_lock, + path: destination.to_owned(), + }) +} + +fn validate_evidence(root: &RepoRoot, evidence: &Evidence) -> Result<()> { if evidence.schema_version != SCHEMA_VERSION || evidence.product != PRODUCT || evidence.source_dirty { return Err(Error::new("release evidence identity mismatch")); } - if evidence.active_exceptions != ordered_exceptions()? { + if evidence.active_exceptions != ordered_exceptions(root)? { return Err(Error::new("release evidence active_exceptions mismatch")); } validate_evidence_text("rust.rustc_verbose", &evidence.rust.rustc_verbose)?; validate_evidence_text("rust.cargo_version", &evidence.rust.cargo_version)?; - validate_native_tools(&evidence.native_tools)?; + validate_native_tools(root, &evidence.native_tools)?; validate_timestamp(&evidence.dependency_policy.advisory_checked_at) } -fn validate_manifest_policy(manifest: &Manifest) -> Result<()> { +fn validate_manifest_policy(root: &RepoRoot, manifest: &Manifest) -> Result<()> { validate_version(&manifest.version)?; validate_evidence_text("rust.rustc_verbose", &manifest.rust.rustc_verbose)?; validate_evidence_text("rust.cargo_version", &manifest.rust.cargo_version)?; - validate_native_tools(&manifest.native_tools)?; + validate_native_tools(root, &manifest.native_tools)?; validate_timestamp(&manifest.dependency_policy.advisory_checked_at)?; validate_artifact_set(&manifest.artifacts)?; for artifact in &manifest.artifacts { @@ -372,7 +956,7 @@ fn validate_timestamp(value: &str) -> Result<()> { Ok(()) } -fn validate_native_tools(tools: &BTreeMap) -> Result<()> { +fn validate_native_tools(root: &RepoRoot, tools: &BTreeMap) -> Result<()> { let actual = tools.keys().map(String::as_str).collect::>(); let expected = TOOL_KEYS.into_iter().collect::>(); if actual != expected { @@ -385,7 +969,7 @@ fn validate_native_tools(tools: &BTreeMap) -> Result<()> { exact_tool(tools, "cargo_generate_rpm", "0.21.0")?; exact_tool(tools, "manifest_validator", env!("CARGO_PKG_VERSION"))?; exact_tool(tools, "signing_mode", "unsigned")?; - let rust_pin = rust_pin()?; + let rust_pin = rust_pin(root)?; exact_tool(tools, "ubuntu_rustc", &format!("rustc {rust_pin}"))?; exact_tool(tools, "ubuntu_cargo", &format!("cargo {rust_pin}"))?; for key in ["ubuntu_image_digest", "fedora_image_digest"] { @@ -421,7 +1005,7 @@ fn exact_tool(tools: &BTreeMap, key: &str, expected: &str) -> Re Ok(()) } -fn validate_identity(key: &str, value: &str) -> Result<()> { +pub(crate) fn validate_identity(key: &str, value: &str) -> Result<()> { let lower = value.to_ascii_lowercase(); let forbidden = [ "token", @@ -934,8 +1518,163 @@ fn rpm_identity(path: &Path) -> Result { }) } -fn validate_live(manifest: &Manifest, payload_root: &Path) -> Result<()> { - let root = workspace_root()?; +pub fn package_member_evidence(path: &Path, version: &str) -> Result { + verify_package_identity(path, version)?; + let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + match artifact_kind(name, Some(version))? { + "tar" => tar_executable_member(path, name), + "deb" => deb_executable_member(path, name), + "rpm" => rpm_executable_member(path, name), + _ => unreachable!(), + } +} + +fn member_record( + package: &str, + format: &str, + installed: &str, + mode: u64, + bytes: Vec, +) -> Result { + if mode != 0o755 || bytes.is_empty() { + return Err(Error::new(format!( + "package executable mismatch: expected mode 0755 and nonempty bytes, actual {mode:o}" + ))); + } + Ok(PackageMemberEvidence { + package_file: package.into(), + format: format.into(), + installed_path: installed.into(), + mode, + bytes: u64::try_from(bytes.len()).map_err(display_error)?, + sha256: digest(&bytes), + }) +} + +fn tar_executable_member(path: &Path, package: &str) -> Result { + let decoder = BufGzDecoder::new(BufReader::new(File::open(path).map_err(display_error)?)); + let mut archive = Archive::new(decoder); + let mut found = None; + for entry in archive.entries().map_err(display_error)? { + let mut entry = entry.map_err(display_error)?; + let member = entry + .path() + .map_err(display_error)? + .to_string_lossy() + .into_owned(); + if member.ends_with("/bin/solstone-linux") { + if found.is_some() || entry.header().entry_type() != EntryType::Regular { + return Err(Error::new( + "tar executable member mismatch: expected unique regular file", + )); + } + let mode = u64::from(entry.header().mode().map_err(display_error)?); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(display_error)?; + found = Some(member_record( + package, + "tar", + "/bin/solstone-linux", + mode, + bytes, + )?); + } + } + found.ok_or_else(|| { + Error::new("tar executable member mismatch: expected executable, actual missing") + }) +} + +fn deb_executable_member(path: &Path, package: &str) -> Result { + let mut archive = ar::Archive::new(File::open(path).map_err(display_error)?); + let mut found = None; + while let Some(entry) = archive.next_entry() { + let mut entry = entry.map_err(display_error)?; + let name = std::str::from_utf8(entry.header().identifier()) + .map_err(display_error)? + .trim_end_matches('/') + .to_owned(); + if name.starts_with("data.tar.") { + if found.is_some() { + return Err(Error::new( + "deb data archive mismatch: expected unique member", + )); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(display_error)?; + found = Some((name, bytes)); + } + } + let (name, bytes) = found + .ok_or_else(|| Error::new("deb data archive mismatch: expected member, actual missing"))?; + let reader: Box = if name.ends_with(".xz") { + Box::new(XzDecoder::new(Cursor::new(bytes))) + } else if name.ends_with(".gz") { + Box::new(GzDecoder::new(Cursor::new(bytes))) + } else if name.ends_with(".zst") { + Box::new(zstd::stream::read::Decoder::new(Cursor::new(bytes)).map_err(display_error)?) + } else { + return Err(Error::new("deb data compression mismatch")); + }; + let mut tar = Archive::new(reader); + let mut executable = None; + for entry in tar.entries().map_err(display_error)? { + let mut entry = entry.map_err(display_error)?; + let member = entry + .path() + .map_err(display_error)? + .to_string_lossy() + .trim_start_matches("./") + .to_owned(); + if member == "usr/bin/solstone-linux" { + if executable.is_some() || entry.header().entry_type() != EntryType::Regular { + return Err(Error::new("deb executable member mismatch")); + } + let mode = u64::from(entry.header().mode().map_err(display_error)?); + let mut body = Vec::new(); + entry.read_to_end(&mut body).map_err(display_error)?; + executable = Some(member_record( + package, + "deb", + "/usr/bin/solstone-linux", + mode, + body, + )?); + } + } + executable.ok_or_else(|| { + Error::new("deb executable member mismatch: expected executable, actual missing") + }) +} + +fn rpm_executable_member(path: &Path, package: &str) -> Result { + let rpm = rpm::Package::open(path).map_err(display_error)?; + let mut executable = None; + for file in rpm.files().map_err(display_error)? { + let file = file.map_err(display_error)?; + if file.metadata.path == Path::new("/usr/bin/solstone-linux") { + if executable.is_some() { + return Err(Error::new( + "rpm executable member mismatch: expected unique file", + )); + } + let mode = u64::from(file.metadata.mode.permissions()); + executable = Some(member_record( + package, + "rpm", + "/usr/bin/solstone-linux", + mode, + file.content, + )?); + } + } + executable.ok_or_else(|| { + Error::new("rpm executable member mismatch: expected executable, actual missing") + }) +} + +fn validate_live(repo: &RepoRoot, manifest: &Manifest, payload_root: &Path) -> Result<()> { + let root = repo.path(); let root_toml: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml")).map_err(display_error)?) .map_err(display_error)?; @@ -950,14 +1689,14 @@ fn validate_live(manifest: &Manifest, payload_root: &Path) -> Result<()> { let product = member_toml["package"]["name"] .as_str() .ok_or_else(|| Error::new("product name missing"))?; - let commit = command(&root, &["git", "rev-parse", "HEAD"])?; + let commit = command(root, &["git", "rev-parse", "HEAD"])?; let lock_digest = digest(&fs::read(root.join("Cargo.lock")).map_err(display_error)?); let makefile = fs::read_to_string(root.join("Makefile")).map_err(display_error)?; let cargo_deny_version = makefile .lines() .find_map(|line| line.strip_prefix("CARGO_DENY_VERSION := ")) .ok_or_else(|| Error::new("cargo-deny version authority missing"))?; - let active_exceptions = ordered_exceptions()?; + let active_exceptions = ordered_exceptions(repo)?; let checks = [ (manifest.product == product, "product"), (manifest.version == version, "version"), @@ -990,11 +1729,11 @@ fn validate_live(manifest: &Manifest, payload_root: &Path) -> Result<()> { "live release evidence mismatch: {field}" ))); } - require_clean_tree(&root, payload_root) + require_clean_tree(root, payload_root) } -fn ordered_exceptions() -> Result> { - let root = workspace_root()?; +fn ordered_exceptions(repo: &RepoRoot) -> Result> { + let root = repo.path(); let deny: toml::Value = toml::from_str(&fs::read_to_string(root.join("deny.toml")).map_err(display_error)?) .map_err(display_error)?; @@ -1013,8 +1752,21 @@ fn ordered_exceptions() -> Result> { fn require_clean_tree(root: &Path, payload_root: &Path) -> Result<()> { let root = root.canonicalize().map_err(display_error)?; - let payload_root = payload_root.canonicalize().map_err(display_error)?; - let dist = root.join("dist"); + let dist = root.join("dist").canonicalize().map_err(display_error)?; + let payload_root = if payload_root.symlink_metadata().is_ok() { + payload_root.canonicalize().map_err(display_error)? + } else { + let parent = payload_root + .parent() + .ok_or_else(|| Error::new("release payload parent mismatch"))? + .canonicalize() + .map_err(display_error)?; + parent.join( + payload_root + .file_name() + .ok_or_else(|| Error::new("release payload basename mismatch"))?, + ) + }; if payload_root != dist && !payload_root.starts_with(&dist) { return Err(Error::new( "release payload root mismatch: expected repository dist path", @@ -1047,15 +1799,8 @@ fn require_clean_tree(root: &Path, payload_root: &Path) -> Result<()> { Ok(()) } -fn workspace_root() -> Result { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .map_err(display_error) -} - -fn rust_pin() -> Result { - let root = workspace_root()?; +fn rust_pin(repo: &RepoRoot) -> Result { + let root = repo.path(); let toolchain: toml::Value = toml::from_str( &fs::read_to_string(root.join("rust-toolchain.toml")).map_err(display_error)?, ) @@ -1093,6 +1838,191 @@ fn verify_schema() -> Result<()> { Ok(()) } +fn verify_pinned_schema(bytes: &[u8], expected_digest: &str, id: &str, label: &str) -> Result<()> { + if digest(bytes) != expected_digest { + return Err(Error::new(format!("{label} schema bytes mismatch"))); + } + let schema: Value = serde_json::from_slice(bytes).map_err(display_error)?; + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["$id"] != id { + return Err(Error::new(format!("{label} schema identity mismatch"))); + } + jsonschema::draft202012::options() + .should_validate_formats(true) + .build(&schema) + .map_err(display_error)?; + Ok(()) +} + +fn transaction_id() -> Result { + let mut bytes = [0_u8; 16]; + File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(display_error)?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn extract_context(bytes: &[u8], destination: &Path, expected_commit: &str) -> Result<()> { + let mut archive = Archive::new(Cursor::new(bytes)); + let mut paths = BTreeSet::new(); + for entry in archive.entries().map_err(display_error)? { + let mut entry = entry.map_err(display_error)?; + if entry.header().entry_type().is_pax_global_extensions() { + let mut body = String::new(); + entry.read_to_string(&mut body).map_err(display_error)?; + let record_length = expected_commit.len() + 12; + if body != format!("{record_length} comment={expected_commit}\n") { + return Err(Error::new( + "immutable context metadata mismatch: expected commit binding, actual other", + )); + } + continue; + } + let path = entry.path().map_err(display_error)?; + let path = path + .to_str() + .ok_or_else(|| Error::new("immutable context path mismatch: expected UTF-8"))?; + let path = if entry.header().entry_type().is_dir() { + path.strip_suffix('/').unwrap_or(path) + } else { + path + }; + portable_path(path)?; + if !paths.insert(path.to_owned()) { + return Err(Error::new(format!( + "immutable context path mismatch: expected unique, actual duplicate {path}" + ))); + } + let output = destination.join(path); + if !output.starts_with(destination) { + return Err(Error::new("immutable context path mismatch")); + } + let mode = entry.header().mode().map_err(display_error)? & 0o7777; + if mode & 0o6000 != 0 { + return Err(Error::new("immutable context mode mismatch")); + } + match entry.header().entry_type() { + EntryType::Directory => { + fs::create_dir_all(&output).map_err(display_error)?; + fs::set_permissions(&output, fs::Permissions::from_mode(mode)) + .map_err(display_error)?; + } + EntryType::Regular => { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).map_err(display_error)?; + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(&output) + .map_err(display_error)?; + std::io::copy(&mut entry, &mut file).map_err(display_error)?; + } + EntryType::Symlink => { + let target = entry + .link_name() + .map_err(display_error)? + .ok_or_else(|| Error::new("immutable context symlink target missing"))?; + let target = target + .to_str() + .ok_or_else(|| Error::new("immutable context symlink target mismatch"))?; + portable_path(target)?; + if target.contains('/') { + return Err(Error::new("immutable context symlink target mismatch")); + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).map_err(display_error)?; + } + symlink(target, output).map_err(display_error)?; + } + other => { + return Err(Error::new(format!( + "immutable context member type mismatch: actual {}", + other.as_byte() + ))); + } + } + } + Ok(()) +} + +fn command_bytes(root: &Path, args: &[&str]) -> Result> { + let output = Command::new(args[0]) + .args(&args[1..]) + .current_dir(root) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!("command mismatch: {}", args[0]))); + } + Ok(output.stdout) +} + +fn xxh64(seed: u64, bytes: &[u8]) -> u64 { + const P1: u64 = 11_400_714_785_074_694_791; + const P2: u64 = 14_029_467_366_897_019_727; + const P3: u64 = 1_609_587_929_392_839_161; + const P4: u64 = 9_650_029_242_287_828_579; + const P5: u64 = 2_870_177_450_012_600_261; + + fn round(accumulator: u64, lane: u64) -> u64 { + (accumulator.wrapping_add(lane.wrapping_mul(P2))) + .rotate_left(31) + .wrapping_mul(P1) + } + + let mut offset = 0; + let mut hash = if bytes.len() >= 32 { + let mut lanes = [ + seed.wrapping_add(P1).wrapping_add(P2), + seed.wrapping_add(P2), + seed, + seed.wrapping_sub(P1), + ]; + while offset + 32 <= bytes.len() { + for lane in &mut lanes { + let value = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + *lane = round(*lane, value); + offset += 8; + } + } + let mut value = lanes[0] + .rotate_left(1) + .wrapping_add(lanes[1].rotate_left(7)) + .wrapping_add(lanes[2].rotate_left(12)) + .wrapping_add(lanes[3].rotate_left(18)); + for lane in lanes { + value ^= round(0, lane); + value = value.wrapping_mul(P1).wrapping_add(P4); + } + value + } else { + seed.wrapping_add(P5) + }; + hash = hash.wrapping_add(bytes.len() as u64); + while offset + 8 <= bytes.len() { + let lane = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + hash ^= round(0, lane); + hash = hash.rotate_left(27).wrapping_mul(P1).wrapping_add(P4); + offset += 8; + } + if offset + 4 <= bytes.len() { + let lane = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + hash ^= u64::from(lane).wrapping_mul(P1); + hash = hash.rotate_left(23).wrapping_mul(P2).wrapping_add(P3); + offset += 4; + } + for byte in &bytes[offset..] { + hash ^= u64::from(*byte).wrapping_mul(P5); + hash = hash.rotate_left(11).wrapping_mul(P1); + } + hash ^= hash >> 33; + hash = hash.wrapping_mul(P2); + hash ^= hash >> 29; + hash = hash.wrapping_mul(P3); + hash ^ (hash >> 32) +} + fn portable_path(path: &str) -> Result<()> { if path.is_empty() || path.starts_with(['/', '\\']) @@ -1214,6 +2144,13 @@ fn is_sha256(value: &str) -> bool { .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) } +pub(crate) fn is_git_commit(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + fn digest(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } @@ -1222,5 +2159,9 @@ fn display_error(error: impl std::fmt::Display) -> Error { Error::new(error.to_string()) } +#[cfg(test)] +mod candidate_tests; +#[cfg(test)] +mod proof_tests; #[cfg(test)] mod tests; diff --git a/crates/rust-release-manifest/src/main.rs b/crates/rust-release-manifest/src/main.rs index 1efa369..705ab7e 100644 --- a/crates/rust-release-manifest/src/main.rs +++ b/crates/rust-release-manifest/src/main.rs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use rust_release_manifest::{ - Evidence, MANIFEST_OK_MESSAGE, RELEASE_DIR_OK_MESSAGE, classify_release_dir, - verify_manifest_mode, write_rendered, + Lane, LaneEmitRequest, MANIFEST_OK_MESSAGE, ProcessEnvironment, ProofHandoffInput, + RELEASE_DIR_OK_MESSAGE, RepoRoot, classify_release_dir, create_candidate, emit_lane_handoff, + emit_proof_handoff, prove_candidate, recover_candidate, verify_manifest_mode, }; -use std::fs; use std::path::PathBuf; #[derive(Parser)] @@ -23,38 +23,181 @@ enum Command { #[arg(long, conflicts_with = "manifest")] release_dir: Option, }, - Render { + #[command(hide = true)] + LaneHandoff(Box), + #[command(hide = true)] + ProofHandoff(Box), + Candidate { + #[command(subcommand)] + command: CandidateCommand, + }, +} + +#[derive(Subcommand)] +enum CandidateCommand { + Create { + #[arg(long)] + expected_release_commit: String, + #[arg(long)] + advisory_descriptor: PathBuf, + }, + Prove { + #[arg(long)] + version: String, #[arg(long)] - evidence: PathBuf, + advisory_descriptor: PathBuf, + }, + Recover { #[arg(long)] - release_dir: PathBuf, + version: String, }, } +#[derive(Args)] +struct LaneHandoffArgs { + #[arg(long)] + lane: String, + #[arg(long)] + invocation_id: String, + #[arg(long)] + source_commit: String, + #[arg(long)] + source_archive_sha256: String, + #[arg(long)] + cargo_lock_sha256: String, + #[arg(long)] + version: String, + #[arg(long)] + target: String, + #[arg(long)] + profile: String, + #[arg(long)] + feature: Vec, + #[arg(long)] + image_digest: String, + #[arg(long)] + baseline_executable: PathBuf, + #[arg(long)] + artifact: Vec, + #[arg(long)] + output: PathBuf, +} + +#[derive(Args)] +struct ProofHandoffArgs { + #[arg(long)] + platform: String, + #[arg(long)] + artifact: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long)] + candidate_digest: String, + #[arg(long)] + ledger_sha256: String, + #[arg(long)] + source_commit: String, + #[arg(long)] + cargo_lock_sha256: String, + #[arg(long)] + proof_image_digest: String, + #[arg(long)] + version: String, +} + fn run() -> Result<(), Box> { - match Cli::parse().command { + let command = Cli::parse().command; + match command { Command::Validate { manifest: Some(path), release_dir: None, } => { - verify_manifest_mode(&path)?; + let root = RepoRoot::resolve()?; + verify_manifest_mode(&root, &path)?; println!("{MANIFEST_OK_MESSAGE}"); } Command::Validate { manifest: None, release_dir: Some(path), } => { - classify_release_dir(&path)?; + let root = RepoRoot::resolve()?; + classify_release_dir(&root, &path)?; println!("{RELEASE_DIR_OK_MESSAGE}"); } Command::Validate { .. } => return Err("exactly one validation path is required".into()), - Command::Render { - evidence, - release_dir, - } => { - let evidence: Evidence = serde_json::from_slice(&fs::read(evidence)?)?; - write_rendered(evidence, &release_dir)?; - println!("Rendered deterministic release manifest and SHA256SUMS."); + Command::LaneHandoff(args) => { + let LaneHandoffArgs { + lane, + invocation_id, + source_commit, + source_archive_sha256, + cargo_lock_sha256, + version, + target, + profile, + feature, + image_digest, + baseline_executable, + artifact, + output, + } = *args; + let lane = match lane.as_str() { + "deb" => Lane::Deb, + "rpm" => Lane::Rpm, + _ => return Err("lane mismatch: expected deb or rpm, actual invalid".into()), + }; + emit_lane_handoff(&LaneEmitRequest { + lane, + invocation_id: &invocation_id, + source_commit: &source_commit, + source_archive_sha256: &source_archive_sha256, + expected_cargo_lock_sha256: &cargo_lock_sha256, + version: &version, + target: &target, + profile: &profile, + features: feature, + image_digest: &image_digest, + baseline_executable: &baseline_executable, + artifacts: artifact, + output: &output, + })?; + } + Command::ProofHandoff(args) => { + emit_proof_handoff(&ProofHandoffInput { + platform: &args.platform, + artifact: &args.artifact, + output: &args.output, + candidate_digest: &args.candidate_digest, + ledger_sha256: &args.ledger_sha256, + source_commit: &args.source_commit, + cargo_lock_sha256: &args.cargo_lock_sha256, + proof_image_digest: &args.proof_image_digest, + version: &args.version, + })?; + } + Command::Candidate { command } => { + let root = RepoRoot::resolve()?; + let processes = ProcessEnvironment::default(); + let status = match command { + CandidateCommand::Create { + expected_release_commit, + advisory_descriptor, + } => create_candidate( + &root, + &expected_release_commit, + &advisory_descriptor, + &processes, + )?, + CandidateCommand::Prove { + version, + advisory_descriptor, + } => prove_candidate(&root, &version, &advisory_descriptor, &processes)?, + CandidateCommand::Recover { version } => { + println!("{}", recover_candidate(&root, &version)?); + return Ok(()); + } + }; + println!("{}", serde_json::to_string(&status)?); } } Ok(()) diff --git a/crates/rust-release-manifest/src/proof_tests.rs b/crates/rust-release-manifest/src/proof_tests.rs new file mode 100644 index 0000000..252d94e --- /dev/null +++ b/crates/rust-release-manifest/src/proof_tests.rs @@ -0,0 +1,1687 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use std::os::unix::fs::PermissionsExt; + +const CANDIDATE_VECTOR: &str = "27e7dd62da4e0022b755f669dd00118a57715aaf088ff7f2a6c322951238494e"; +const BUNDLE_VECTOR: &str = "cd214a005b2186a7eb25e9fd756561fb9c6e47e02004047c1cd5132106580a3e"; + +#[test] +fn candidate_schemas_are_digest_and_identity_pinned() { + verify_candidate_schemas().unwrap(); + assert_eq!(digest(ledger_schema_bytes()), LEDGER_SCHEMA_SHA256); + assert_eq!(digest(proof_schema_bytes()), PROOF_SCHEMA_SHA256); +} + +fn payload_vector() -> (tempfile::TempDir, Vec) { + let temp = tempfile::tempdir().unwrap(); + for (name, bytes) in [ + ("zeta", b"alpha\n".as_slice()), + ("alpha", b"bravo".as_slice()), + ("middle", b"charlie-data".as_slice()), + ("beta", b"D".as_slice()), + ("omega", b"echo echo".as_slice()), + ] { + fs::write(temp.path().join(name), bytes).unwrap(); + } + let payload = ["zeta", "alpha", "middle", "beta", "omega"] + .map(|name| artifact(&temp.path().join(name)).unwrap()) + .to_vec(); + (temp, payload) +} + +#[test] +fn candidate_digest_has_exact_payload_line_formula() { + let (_temp, payload) = payload_vector(); + let expected = concat!( + "f144a6907dc4284d1f9fe6a7d9b9ff53c02c1d07ba68f24d413d7ff7f757a782 5 alpha\n", + "3f39d5c348e5b79d06e842c114e6cc571583bbf44e4b0ebfda1a01ec05745d43 1 beta\n", + "3a76f368f7ec3090e97437137f9fd6e8999fd4db5854adfff12248ebb005e521 12 middle\n", + "51e04812dab5b72b9149da420a1528cb556d6795d6c5d7ce11ea94841313e595 9 omega\n", + "b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060 6 zeta\n", + ); + let stream = candidate_digest_input(&payload).unwrap(); + assert_eq!(stream, expected.as_bytes()); + assert_eq!(candidate_digest(&payload).unwrap(), CANDIDATE_VECTOR); + let single_space = expected.replace(" ", " "); + assert_ne!(digest(single_space.as_bytes()), CANDIDATE_VECTOR); + + let mut reversed = payload; + reversed.reverse(); + assert_eq!(candidate_digest_input(&reversed).unwrap(), stream); +} + +fn proof_map() -> BTreeMap> { + BTreeMap::from([ + ("tar-x86_64".into(), b"tar-proof\n".to_vec()), + ("debian-amd64".into(), b"deb-proof\n".to_vec()), + ("rpm-x86_64".into(), b"rpm-proof\n".to_vec()), + ]) +} + +#[test] +fn bundle_digest_has_exact_compact_sorted_json_formula() { + let candidate = "1".repeat(64); + let ledger = b"{\"ledger\":\"fixed\"}\n"; + let proofs = proof_map(); + let expected = concat!( + "{\"candidate_digest\":\"1111111111111111111111111111111111111111111111111111111111111111\",", + "\"ledger_sha256\":\"4082c6007708fd77afd869d0624adc7d2c09dd6deb6505f7d5741cf7ed458524\",", + "\"proofs\":{\"debian-amd64\":\"9689b99e6172887d286f44d6cbaef799b60d7687b0f8e0741494c7644e494412\",", + "\"rpm-x86_64\":\"d1b8164dbc832ff48f7980309a7e5b505c92dca0108d8b6f030a61c1757ed93b\",", + "\"tar-x86_64\":\"6eaea6b88a31cf0370426e36d9756aa6280c2d7ff1c8512203ab5be9ba82bb88\"}}", + ); + let input = bundle_digest_input(&candidate, ledger, &proofs).unwrap(); + assert_eq!(input, expected.as_bytes()); + assert_eq!( + bundle_digest(&candidate, ledger, &proofs).unwrap(), + BUNDLE_VECTOR + ); + + let without_ledger = serde_json::json!({ + "candidate_digest": candidate, + "proofs": serde_json::from_slice::(&input).unwrap()["proofs"].clone(), + }); + assert_ne!( + digest(serde_json::to_string(&without_ledger).unwrap().as_bytes()), + BUNDLE_VECTOR + ); +} + +fn proof_bindings() -> ProofBindings { + ProofBindings { + platform: "debian-amd64".into(), + candidate_digest: "1".repeat(64), + ledger_sha256: "2".repeat(64), + source_commit: "3".repeat(40), + cargo_lock_sha256: "4".repeat(64), + artifact_basename: "solstone-linux_1.0.0-1_amd64.deb".into(), + artifact_bytes: 123, + artifact_sha256: "5".repeat(64), + proof_image_digest: format!("sha256:{}", "6".repeat(64)), + os_release: "Ubuntu 22.04.5 LTS".into(), + package_manager_version: "dpkg 1.21.22".into(), + install_command: vec!["dpkg".into(), "--install".into(), "package.deb".into()], + install_exit_status: 0, + version_command: vec!["/usr/bin/solstone-linux".into(), "--version".into()], + version_exit_status: 0, + executable_path: "/usr/bin/solstone-linux".into(), + executable_mode: 0o755, + executable_sha256: "7".repeat(64), + version_output: "solstone-linux 1.0.0".into(), + result: "pass".into(), + policy_checked_at: "2026-07-20T12:00:00Z".into(), + validation_time: "2026-07-20T14:00:00Z".into(), + } +} + +fn valid_proof() -> Value { + serde_json::json!({ + "schema_version": 1, + "platform": "debian-amd64", + "candidate_digest": "1".repeat(64), + "ledger_sha256": "2".repeat(64), + "source_commit": "3".repeat(40), + "cargo_lock_sha256": "4".repeat(64), + "artifact_basename": "solstone-linux_1.0.0-1_amd64.deb", + "artifact_bytes": 123, + "artifact_sha256": "5".repeat(64), + "proof_image_digest": format!("sha256:{}", "6".repeat(64)), + "os_release": "Ubuntu 22.04.5 LTS", + "package_manager_version": "dpkg 1.21.22", + "install_command": ["dpkg", "--install", "package.deb"], + "install_exit_status": 0, + "version_command": ["/usr/bin/solstone-linux", "--version"], + "version_exit_status": 0, + "executable_path": "/usr/bin/solstone-linux", + "executable_mode": 493, + "executable_sha256": "7".repeat(64), + "version_output": "solstone-linux 1.0.0", + "result": "pass", + "proof_time": "2026-07-20T13:00:00Z", + "architecture": "amd64", + "network": "none", + "isolation": "fresh-container" + }) +} + +fn mutated_value(field: &str) -> Value { + match field { + "platform" => Value::String("rpm-x86_64".into()), + "candidate_digest" | "ledger_sha256" | "cargo_lock_sha256" | "artifact_sha256" + | "executable_sha256" => Value::String("a".repeat(64)), + "source_commit" => Value::String("a".repeat(40)), + "artifact_basename" => Value::String("other.deb".into()), + "artifact_bytes" => Value::from(124), + "proof_image_digest" => Value::String(format!("sha256:{}", "a".repeat(64))), + "os_release" => Value::String("debian 13".into()), + "package_manager_version" => Value::String("apt 9.9".into()), + "install_command" => serde_json::json!(["dpkg", "--unpack", "package.deb"]), + "install_exit_status" | "version_exit_status" => Value::from(1), + "version_command" => serde_json::json!(["/usr/bin/solstone-linux", "help"]), + "executable_path" => Value::String("/usr/local/bin/solstone-linux".into()), + "executable_mode" => Value::from(0o700), + "version_output" => Value::String("solstone-linux 9.9.9".into()), + "result" => Value::String("fail".into()), + "proof_time" => Value::String("2026-07-20T11:59:59Z".into()), + _ => unreachable!(), + } +} + +const BOUND_FIELDS: [&str; 21] = [ + "platform", + "candidate_digest", + "ledger_sha256", + "source_commit", + "cargo_lock_sha256", + "artifact_basename", + "artifact_bytes", + "artifact_sha256", + "proof_image_digest", + "os_release", + "package_manager_version", + "install_command", + "install_exit_status", + "version_command", + "version_exit_status", + "executable_path", + "executable_mode", + "executable_sha256", + "version_output", + "result", + "proof_time", +]; + +#[test] +fn proof_validator_rejects_each_mutated_binding() { + let expected = proof_bindings(); + validate_candidate_proof(&valid_proof(), &expected).unwrap(); + for field in BOUND_FIELDS { + let mut proof = valid_proof(); + proof[field] = mutated_value(field); + assert!( + validate_candidate_proof(&proof, &expected).is_err(), + "accepted mutated {field}" + ); + } + + let mut source = valid_proof(); + source["source_commit"] = Value::String("a".repeat(40)); + assert_eq!( + source["candidate_digest"], + valid_proof()["candidate_digest"] + ); + assert!(validate_candidate_proof(&source, &expected).is_err()); + let mut lock = valid_proof(); + lock["cargo_lock_sha256"] = Value::String("a".repeat(64)); + assert_eq!(lock["candidate_digest"], valid_proof()["candidate_digest"]); + assert!(validate_candidate_proof(&lock, &expected).is_err()); +} + +#[test] +fn proof_schema_rejects_each_missing_binding() { + let expected = proof_bindings(); + for field in BOUND_FIELDS { + let mut proof = valid_proof(); + proof.as_object_mut().unwrap().remove(field); + assert!( + validate_candidate_proof(&proof, &expected).is_err(), + "accepted missing {field}" + ); + } +} + +#[test] +fn xxh64_matches_pinned_vectors_and_cargo_deny_layout() { + assert_eq!(xxh64(0, b""), 0xef46_db37_51d8_e999); + assert_eq!(xxh64(0, &[42]), 0x0a9e_dece_beb0_3ae4); + assert_eq!(xxh64(0, b"Hello, world!\0"), 0x7b06_c531_ea43_e89f); + let long = (0_u8..100).collect::>(); + assert_eq!(xxh64(0, &long), 0x6ac1_e580_3216_6597); + let derived = advisory_db_directory("file://localhost/advisory-db").unwrap(); + assert_eq!(xxh64(0xca80_de71, b""), 0xecd8_91c6_6d7e_1845); + assert_eq!(derived, "advisory-db-f652704a6eab6a9e"); +} + +#[test] +fn transaction_ids_are_lowercase_hex() { + let first = transaction_id().unwrap(); + let second = transaction_id().unwrap(); + assert_eq!(first.len(), 32); + assert!( + first + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + ); + assert_ne!(first, second); +} + +#[test] +fn atomic_publish_failure_removes_only_the_owned_temporary() { + let directory = tempfile::tempdir().unwrap(); + let owned = directory.path().join(".ledger.owned.tmp"); + let foreign = directory.path().join(".ledger.foreign.tmp"); + fs::write(&owned, b"owned").unwrap(); + fs::write(&foreign, b"foreign").unwrap(); + let error = + finish_atomic_publish(&owned, Err(Error::new("synthetic publish failure"))).unwrap_err(); + assert!(error.to_string().contains("synthetic publish failure")); + assert!(!owned.exists()); + assert_eq!(fs::read(&foreign).unwrap(), b"foreign"); +} + +#[test] +fn ledger_fsync_failure_uses_the_owned_publish_cleanup_invariant() { + let directory = tempfile::tempdir().unwrap(); + let owned = directory.path().join(".ledger.fsync.tmp"); + let foreign = directory.path().join(".ledger.foreign.tmp"); + fs::write(&owned, b"owned").unwrap(); + fs::write(&foreign, b"foreign").unwrap(); + let error = finish_atomic_publish(&owned, Err(Error::new("ledger fsync failure"))).unwrap_err(); + assert!(error.to_string().contains("ledger fsync failure")); + assert!(!owned.exists()); + assert_eq!(fs::read(&foreign).unwrap(), b"foreign"); +} + +#[test] +fn ledger_chmod_failure_uses_the_owned_publish_cleanup_invariant() { + let directory = tempfile::tempdir().unwrap(); + let owned = directory.path().join(".ledger.chmod.tmp"); + let foreign = directory.path().join(".ledger.foreign.tmp"); + fs::write(&owned, b"owned").unwrap(); + fs::write(&foreign, b"foreign").unwrap(); + let error = finish_atomic_publish(&owned, Err(Error::new("ledger chmod failure"))).unwrap_err(); + assert!(error.to_string().contains("ledger chmod failure")); + assert!(!owned.exists()); + assert_eq!(fs::read(&foreign).unwrap(), b"foreign"); +} + +#[test] +fn atomic_rename_failure_removes_owned_temp_and_preserves_foreign_temp() { + let directory = tempfile::tempdir().unwrap(); + let owned = directory.path().join(".ledger.owned.tmp"); + let foreign = directory.path().join(".ledger.foreign.tmp"); + let destination = directory.path().join("ledger.json"); + fs::write(&owned, b"owned").unwrap(); + fs::write(&foreign, b"foreign").unwrap(); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("keep"), b"keep").unwrap(); + let publish = fs::rename(&owned, &destination).map_err(display_error); + let error = finish_atomic_publish(&owned, publish).unwrap_err(); + assert!(!error.to_string().is_empty()); + assert!(!owned.exists()); + assert_eq!(fs::read(&foreign).unwrap(), b"foreign"); + assert_eq!(fs::read(destination.join("keep")).unwrap(), b"keep"); +} + +#[test] +fn atomic_temp_creation_failure_does_not_mutate_read_only_parent() { + let directory = tempfile::tempdir().unwrap(); + let foreign = directory.path().join("foreign.tmp"); + fs::write(&foreign, b"foreign").unwrap(); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o555)).unwrap(); + let result = atomic_write_0644(&directory.path().join("ledger.json"), b"candidate\n"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(result.is_err()); + assert_eq!(fs::read(&foreign).unwrap(), b"foreign"); + assert!(!directory.path().join("ledger.json").exists()); +} + +#[test] +fn fixed_payload_and_ledger_serialization_are_reproducible() { + let (_temp, mut payload) = payload_vector(); + payload.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes())); + let candidate = candidate_digest(&payload).unwrap(); + let ledger = CandidateLedger { + schema_version: 1, + product: PRODUCT.into(), + version: "1.0.0".into(), + source: LedgerSource { + commit: "a".repeat(40), + archive_sha256: "b".repeat(64), + cargo_lock_sha256: "c".repeat(64), + }, + validator: LedgerValidator { + version: env!("CARGO_PKG_VERSION").into(), + }, + target: LedgerTarget { + triple: TARGET_TRIPLE.into(), + profile: "release".into(), + features: Vec::new(), + }, + policy: LedgerPolicy { + cargo_deny_version: CARGO_DENY_VERSION.into(), + deterministic_gate: "pass".into(), + licenses_bans_sources: "pass".into(), + advisories: "pass".into(), + checked_at: "2026-07-20T12:00:00Z".into(), + active_exceptions: vec!["RUSTSEC-2026-0194".into(), "RUSTSEC-2026-0195".into()], + }, + advisory_cohort: LedgerAdvisory { + source_id: "rustsec snapshot".into(), + commit: "d".repeat(40), + archive_sha256: "e".repeat(64), + acquired_at: "2026-07-20T11:00:00Z".into(), + }, + images: LedgerImages { + engine: "podman".into(), + engine_version: "podman version 5.8.3".into(), + ubuntu_image_id: format!("sha256:{}", "f".repeat(64)), + fedora_image_id: format!("sha256:{}", "1".repeat(64)), + }, + tools: BTreeMap::new(), + payload, + package_members: Vec::new(), + expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + candidate_digest: candidate, + }; + let first = canonical_json(&serde_json::to_value(&ledger).unwrap()).unwrap(); + let second = canonical_json(&serde_json::to_value(&ledger).unwrap()).unwrap(); + assert_eq!(first, second); + assert_eq!(digest(&first), digest(&second)); + + let mut foreign = serde_json::to_value(&ledger).unwrap(); + foreign.as_object_mut().unwrap().insert( + "completion_time".into(), + Value::String("2026-07-20T12:00:01Z".into()), + ); + assert!(serde_json::from_value::(foreign).is_err()); +} + +struct RetainedFixture { + repo: crate::candidate_tests::TestRepo, + advisory_db: tempfile::TempDir, + _descriptor_dir: tempfile::TempDir, + descriptor: PathBuf, + ledger: CandidateLedger, + ledger_bytes: Vec, +} + +fn retained_fixture() -> RetainedFixture { + retained_fixture_from(crate::candidate_tests::fixture()) +} + +fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixture { + let payload = repo.root.path().join("dist/rust"); + fs::create_dir_all(&payload).unwrap(); + let products = crate::tests::release_fixture(); + for entry in fs::read_dir(products.path()).unwrap() { + let entry = entry.unwrap(); + fs::copy(entry.path(), payload.join(entry.file_name())).unwrap(); + } + crate::tests::write_rendered(crate::tests::evidence(), &payload).unwrap(); + + let mut artifacts = fs::read_dir(&payload) + .unwrap() + .map(|entry| artifact(&entry.unwrap().path()).unwrap()) + .collect::>(); + artifacts.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes())); + let mut members = artifacts + .iter() + .filter(|item| item.path != CHECKSUM_NAME && !item.path.ends_with("-manifest.json")) + .map(|item| package_member_evidence(&payload.join(&item.path), "1.0.0").unwrap()) + .collect::>(); + members.sort_by(|left, right| { + left.package_file + .as_bytes() + .cmp(right.package_file.as_bytes()) + }); + + let advisory_db = crate::candidate_tests::git_repo(); + let advisory_commit = command(advisory_db.path(), &["git", "rev-parse", "HEAD"]).unwrap(); + let advisory_archive = command_bytes( + advisory_db.path(), + &["git", "archive", "--format=tar", "HEAD"], + ) + .unwrap(); + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let descriptor_dir = tempfile::tempdir().unwrap(); + let descriptor = crate::candidate_tests::descriptor( + descriptor_dir.path(), + advisory_db.path(), + None, + "2026-01-01T00:00:00Z", + ); + let policy = ReleaseImages::from_root(repo.root.path()).unwrap(); + let ubuntu = proof_image_identity(&policy.build_ubuntu).digest; + let fedora = proof_image_identity(&policy.build_fedora).digest; + let mut tools = crate::tests::tools(); + tools.insert("container_engine".into(), "podman version 5.8.3".into()); + tools.insert( + "ubuntu_image_digest".into(), + ubuntu.strip_prefix("sha256:").unwrap().into(), + ); + tools.insert( + "fedora_image_digest".into(), + fedora.strip_prefix("sha256:").unwrap().into(), + ); + let ledger = CandidateLedger { + schema_version: 1, + product: PRODUCT.into(), + version: "1.0.0".into(), + source: LedgerSource { + commit: repo.commit.clone(), + archive_sha256: digest( + &command_bytes( + repo.root.path(), + &["git", "archive", "--format=tar", "HEAD"], + ) + .unwrap(), + ), + cargo_lock_sha256: repo.cargo_lock_sha256.clone(), + }, + validator: LedgerValidator { + version: env!("CARGO_PKG_VERSION").into(), + }, + target: LedgerTarget { + triple: TARGET_TRIPLE.into(), + profile: "release".into(), + features: vec![], + }, + policy: LedgerPolicy { + cargo_deny_version: repo.cargo_deny_version.clone(), + deterministic_gate: "pass".into(), + licenses_bans_sources: "pass".into(), + advisories: "pass".into(), + checked_at: now.clone(), + active_exceptions: repo.exceptions.clone(), + }, + advisory_cohort: LedgerAdvisory { + source_id: "rustsec snapshot 1".into(), + commit: advisory_commit, + archive_sha256: digest(&advisory_archive), + acquired_at: "2026-01-01T00:00:00Z".into(), + }, + images: LedgerImages { + engine: "podman".into(), + engine_version: "podman version 5.8.3".into(), + ubuntu_image_id: ubuntu, + fedora_image_id: fedora, + }, + tools, + payload: artifacts.clone(), + package_members: members, + expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + candidate_digest: candidate_digest(&artifacts).unwrap(), + }; + let ledger_bytes = ledger_bytes(&repo.root, &payload, &ledger).unwrap(); + let evidence = repo.root.path().join("dist/rust-evidence/1.0.0"); + fs::create_dir_all(evidence.join("proofs")).unwrap(); + atomic_write_0644(&evidence.join("ledger.json"), &ledger_bytes).unwrap(); + for id in PROOF_IDS { + write_valid_proof(&repo.root, &ledger, &ledger_bytes, id); + } + RetainedFixture { + repo, + advisory_db, + _descriptor_dir: descriptor_dir, + descriptor, + ledger, + ledger_bytes, + } +} + +fn write_valid_proof(root: &RepoRoot, ledger: &CandidateLedger, ledger_bytes: &[u8], id: &str) { + let policy = ReleaseImages::from_root(root.path()).unwrap(); + let platform = policy.proof_policy(id).unwrap(); + let artifact = proof_artifact(ledger, id).unwrap(); + let member = proof_member(ledger, id).unwrap(); + let proof = CandidateProof { + schema_version: 1, + platform: id.into(), + candidate_digest: ledger.candidate_digest.clone(), + ledger_sha256: digest(ledger_bytes), + source_commit: ledger.source.commit.clone(), + cargo_lock_sha256: ledger.source.cargo_lock_sha256.clone(), + artifact_basename: artifact.path.clone(), + artifact_bytes: artifact.bytes, + artifact_sha256: artifact.sha256.clone(), + proof_image_digest: platform.image_digest.clone(), + os_release: platform.os_release.clone(), + package_manager_version: platform.package_manager_version.clone(), + install_command: platform.install_command.clone(), + install_exit_status: 0, + version_command: platform.version_command.clone(), + version_exit_status: 0, + executable_path: platform.executable_path.clone(), + executable_mode: platform.executable_mode, + executable_sha256: member.sha256.clone(), + version_output: "solstone-linux 1.0.0".into(), + result: "pass".into(), + proof_time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + architecture: if id == "debian-amd64" { + "amd64" + } else { + "x86_64" + } + .into(), + network: "none".into(), + isolation: "fresh-container".into(), + dry_run_passed: (id == "tar-x86_64").then_some(true), + isolated_prefix_passed: (id == "tar-x86_64").then_some(true), + }; + let bytes = canonical_json(&serde_json::to_value(proof).unwrap()).unwrap(); + atomic_write_0644( + &root + .path() + .join("dist/rust-evidence/1.0.0/proofs") + .join(format!("{id}.json")), + &bytes, + ) + .unwrap(); +} + +#[test] +fn candidate_status_requires_three_fully_valid_proofs() { + let fixture = retained_fixture(); + let status = + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).unwrap(); + assert_eq!(status.status, "candidate-proven"); + let proof = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs/tar-x86_64.json"); + let bytes = fs::read(&proof).unwrap(); + fs::remove_file(&proof).unwrap(); + assert!(candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_err()); + fs::write(&proof, &bytes).unwrap(); + let mut value: Value = serde_json::from_slice(&bytes).unwrap(); + value["result"] = Value::String("fail".into()); + fs::write(&proof, canonical_json(&value).unwrap()).unwrap(); + assert!(candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_err()); +} + +#[test] +fn bundle_digest_callsite_invariant_requires_validated_exact_inventory() { + let fixture = retained_fixture(); + let status = + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).unwrap(); + assert_eq!(status.proofs.keys().cloned().collect::>(), PROOF_IDS); + assert_eq!( + status.candidate_digest, + candidate_digest(&fixture.ledger.payload).unwrap() + ); + assert_eq!(status.ledger_sha256, digest(&fixture.ledger_bytes)); +} + +#[test] +fn promoted_package_member_reconciliation_is_stable_after_strict_classification() { + let fixture = retained_fixture(); + validate_ledger( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &fixture.ledger, + ) + .unwrap(); + let before = fixture.ledger.package_members.clone(); + validate_ledger( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &fixture.ledger, + ) + .unwrap(); + assert_eq!(fixture.ledger.package_members, before); +} + +#[test] +fn promoted_ledger_construction_has_no_unvalidated_fallible_input() { + let fixture = retained_fixture(); + validate_ledger( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &fixture.ledger, + ) + .unwrap(); + assert_eq!( + candidate_digest(&fixture.ledger.payload).unwrap(), + fixture.ledger.candidate_digest + ); + assert_eq!(fixture.ledger.payload.len(), 5); + assert_eq!(fixture.ledger.package_members.len(), 3); +} + +#[test] +fn promoted_ledger_schema_and_privacy_precede_canonical_bytes() { + let fixture = retained_fixture(); + let bytes = ledger_bytes( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &fixture.ledger, + ) + .unwrap(); + assert_eq!(bytes, fixture.ledger_bytes); + let parsed: CandidateLedger = serde_json::from_slice(&bytes).unwrap(); + validate_ledger( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &parsed, + ) + .unwrap(); +} + +#[test] +fn status_and_recovery_reject_every_retained_binding_mutation() { + let fixture = retained_fixture(); + assert_eq!( + recover_candidate(&fixture.repo.root, "1.0.0").unwrap(), + "retained-candidate-valid" + ); + let fields = [ + "platform", + "candidate_digest", + "ledger_sha256", + "source_commit", + "cargo_lock_sha256", + "artifact_basename", + "artifact_bytes", + "artifact_sha256", + "proof_image_digest", + "os_release", + "package_manager_version", + "install_command", + "install_exit_status", + "version_command", + "version_exit_status", + "executable_path", + "executable_mode", + "executable_sha256", + "version_output", + "result", + "proof_time", + "architecture", + "network", + "isolation", + ]; + for id in PROOF_IDS { + let path = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs") + .join(format!("{id}.json")); + let original = fs::read(&path).unwrap(); + let applicable = fields + .into_iter() + .chain((id == "tar-x86_64").then_some("dry_run_passed")) + .chain((id == "tar-x86_64").then_some("isolated_prefix_passed")); + for field in applicable { + let mut value: Value = serde_json::from_slice(&original).unwrap(); + let slot = value.get_mut(field).unwrap(); + *slot = match slot { + Value::String(text) => Value::String(format!("{text}-skew")), + Value::Number(number) => Value::Number((number.as_u64().unwrap() + 1).into()), + Value::Array(argv) => { + let mut argv = argv.clone(); + argv.push(Value::String("--skew".into())); + Value::Array(argv) + } + Value::Bool(value) => Value::Bool(!*value), + other => panic!("unexpected mutation value {other:?}"), + }; + fs::write(&path, canonical_json(&value).unwrap()).unwrap(); + assert!( + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes) + .is_err(), + "status accepted {id} {field}" + ); + assert!( + recover_candidate(&fixture.repo.root, "1.0.0").is_err(), + "recovery accepted {id} {field}" + ); + let before = directory_digest_map(&fixture.repo.root.path().join("dist")); + let tripwire_dir = tempfile::tempdir().unwrap(); + let tripwire = tripwire_dir.path().join("proof-run"); + let (_bin, processes) = proof_processes(&tripwire); + assert!( + prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &processes) + .is_err() + ); + assert_eq!( + directory_digest_map(&fixture.repo.root.path().join("dist")), + before, + "prove mutated retained state for {id} {field}" + ); + assert!(!tripwire.exists(), "prove ran container for {id} {field}"); + fs::write(&path, &original).unwrap(); + } + } + + let ledger_path = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json"); + let original = fs::read(&ledger_path).unwrap(); + let mut ledger_pointers = vec![ + "/candidate_digest", + "/images/engine", + "/images/ubuntu_image_id", + "/images/fedora_image_id", + "/images/engine_version", + "/target/triple", + "/target/profile", + "/target/features", + "/source/commit", + "/source/cargo_lock_sha256", + "/source/archive_sha256", + "/version", + "/advisory_cohort/source_id", + "/advisory_cohort/commit", + "/advisory_cohort/archive_sha256", + "/advisory_cohort/acquired_at", + "/policy/checked_at", + "/policy/deterministic_gate", + "/policy/licenses_bans_sources", + "/policy/advisories", + "/policy/active_exceptions", + "/expected_proof_ids", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + ledger_pointers.extend(TOOL_KEYS.map(|key| format!("/tools/{key}"))); + for index in 0..fixture.ledger.payload.len() { + for field in ["path", "bytes", "sha256"] { + ledger_pointers.push(format!("/payload/{index}/{field}")); + } + } + for index in 0..fixture.ledger.package_members.len() { + for field in [ + "package_file", + "format", + "installed_path", + "mode", + "bytes", + "sha256", + ] { + ledger_pointers.push(format!("/package_members/{index}/{field}")); + } + } + for pointer in ledger_pointers { + let mut value: Value = serde_json::from_slice(&original).unwrap(); + let slot = value.pointer_mut(&pointer).unwrap(); + *slot = match slot { + Value::String(text) => Value::String(format!("{text}-skew")), + Value::Array(values) => { + let mut values = values.clone(); + values.push(Value::String("foreign".into())); + Value::Array(values) + } + Value::Number(number) => Value::Number((number.as_u64().unwrap() + 1).into()), + other => panic!("unexpected ledger mutation value {other:?}"), + }; + let mutated = canonical_json(&value).unwrap(); + fs::write(&ledger_path, &mutated).unwrap(); + let parsed: CandidateLedger = serde_json::from_slice(&mutated).unwrap(); + assert!( + candidate_status(&fixture.repo.root, &parsed, &mutated).is_err(), + "status accepted ledger mutation {pointer}" + ); + assert!( + recover_candidate(&fixture.repo.root, "1.0.0").is_err(), + "recovery accepted ledger mutation {pointer}" + ); + fs::write(&ledger_path, &original).unwrap(); + } + for pointer in ["/payload", "/package_members"] { + let mut value: Value = serde_json::from_slice(&original).unwrap(); + let values = value.pointer_mut(pointer).unwrap().as_array_mut().unwrap(); + values.push(values[0].clone()); + let mutated = canonical_json(&value).unwrap(); + fs::write(&ledger_path, &mutated).unwrap(); + let parsed: CandidateLedger = serde_json::from_slice(&mutated).unwrap(); + assert!(candidate_status(&fixture.repo.root, &parsed, &mutated).is_err()); + assert!(recover_candidate(&fixture.repo.root, "1.0.0").is_err()); + fs::write(&ledger_path, &original).unwrap(); + } +} + +#[test] +fn recover_candidate_rejects_mismatched_checkout_without_writing() { + let fixture = retained_fixture(); + let evidence = fixture.repo.root.path().join("dist/rust-evidence"); + let before = directory_digest_map(&evidence); + fs::write( + fixture.repo.root.path().join("Cargo.lock"), + b"checkout skew\n", + ) + .unwrap(); + assert!(recover_candidate(&fixture.repo.root, "1.0.0").is_err()); + assert_eq!(directory_digest_map(&evidence), before); +} + +fn directory_digest_map(root: &Path) -> BTreeMap { + fn walk(base: &Path, path: &Path, out: &mut BTreeMap) { + for entry in fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + out.insert( + entry.path().strip_prefix(base).unwrap().into(), + "directory".into(), + ); + walk(base, &entry.path(), out); + } else { + out.insert( + entry.path().strip_prefix(base).unwrap().into(), + digest(&fs::read(entry.path()).unwrap()), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn proof_processes(tripwire: &Path) -> (tempfile::TempDir, ProcessEnvironment) { + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("podman"); + fs::write( + &script, + format!( + "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'podman version 5.8.3'; exit 0; fi\nif [ \"$1\" = image ] && [ \"$2\" = inspect ]; then id=${{3##*sha256:}}; printf '[{{\"Id\":\"sha256:%s\",\"Os\":\"linux\",\"Architecture\":\"amd64\"}}]' \"$id\"; exit 0; fi\nprintf called > '{}'\nexit 99\n", + tripwire.display() + ), + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:/usr/bin:/bin", temp.path().display()); + ( + temp, + ProcessEnvironment::with_path(OsStr::new(path.as_str())), + ) +} + +fn proof_output_processes( + templates: &Path, + fail_platform: Option<&str>, + build_tripwire: &Path, +) -> (tempfile::TempDir, ProcessEnvironment) { + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("podman"); + fs::write( + &script, + format!( + r#"#!/bin/sh +if [ "$1" = --version ]; then echo 'podman version 5.8.3'; exit 0; fi +if [ "$1" = image ] && [ "$2" = inspect ]; then id=${{3##*sha256:}}; printf '[{{"Id":"sha256:%s","Os":"linux","Architecture":"amd64"}}]' "$id"; exit 0; fi +if [ "$1" = build ] || [ "$1" = buildx ]; then printf called > '{tripwire}'; exit 98; fi +platform= +output= +previous= +for argument in "$@"; do + [ "$previous" = --platform ] && platform=$argument + case "$argument" in type=bind,src=*,dst=/evidence) output=${{argument#type=bind,src=}}; output=${{output%,dst=/evidence}};; esac + previous=$argument +done +[ "$platform" = '{fail}' ] && exit 97 +cp '{templates}/'$platform.json "$output/proof.json" +"#, + tripwire = build_tripwire.display(), + fail = fail_platform.unwrap_or("never"), + templates = templates.display(), + ), + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:/usr/bin:/bin", temp.path().display()); + ( + temp, + ProcessEnvironment::with_path(OsStr::new(path.as_str())), + ) +} + +fn proof_output_processes_with_source_mutation( + templates: &Path, + cargo_lock: &Path, + tripwire: &Path, +) -> (tempfile::TempDir, ProcessEnvironment) { + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("podman"); + fs::write( + &script, + format!( + r#"#!/bin/sh +if [ "$1" = --version ]; then echo 'podman version 5.8.3'; exit 0; fi +if [ "$1" = image ] && [ "$2" = inspect ]; then id=${{3##*sha256:}}; printf '[{{"Id":"sha256:%s","Os":"linux","Architecture":"amd64"}}]' "$id"; exit 0; fi +if [ "$1" = build ] || [ "$1" = buildx ]; then printf called > '{tripwire}'; exit 98; fi +platform=; output=; previous= +for argument in "$@"; do + [ "$previous" = --platform ] && platform=$argument + case "$argument" in type=bind,src=*,dst=/evidence) output=${{argument#type=bind,src=}}; output=${{output%,dst=/evidence}};; esac + previous=$argument +done +/bin/cp '{templates}/'$platform.json "$output/proof.json" +/usr/bin/printf 'mid-proof source change\n' > '{cargo_lock}' +"#, + templates = templates.display(), + cargo_lock = cargo_lock.display(), + tripwire = tripwire.display(), + ), + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:/usr/bin:/bin", temp.path().display()); + ( + temp, + ProcessEnvironment::with_path(OsStr::new(path.as_str())), + ) +} + +#[test] +fn prove_candidate_validates_all_existing_proofs_before_any_write() { + let fixture = retained_fixture(); + assert!(fixture.advisory_db.path().is_dir()); + let proofs = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs"); + let absent = proofs.join("debian-amd64.json"); + fs::remove_file(&absent).unwrap(); + let corrupt = proofs.join("tar-x86_64.json"); + let mut value: Value = serde_json::from_slice(&fs::read(&corrupt).unwrap()).unwrap(); + value["os_release"] = Value::String("foreign platform".into()); + fs::write(&corrupt, canonical_json(&value).unwrap()).unwrap(); + let before = directory_digest_map(&fixture.repo.root.path().join("dist")); + let tripwire_dir = tempfile::tempdir().unwrap(); + let tripwire = tripwire_dir.path().join("container-run"); + let (_bin, processes) = proof_processes(&tripwire); + assert!(prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &processes).is_err()); + assert_eq!( + directory_digest_map(&fixture.repo.root.path().join("dist")), + before + ); + assert!(!absent.exists()); + assert!(!tripwire.exists()); +} + +#[test] +fn prove_candidate_rejects_source_change_before_proof_writes() { + let fixture = retained_fixture(); + let before = directory_digest_map(&fixture.repo.root.path().join("dist")); + fs::write( + fixture.repo.root.path().join("Cargo.lock"), + b"source changed\n", + ) + .unwrap(); + let tripwire_dir = tempfile::tempdir().unwrap(); + let tripwire = tripwire_dir.path().join("container-run"); + let (_bin, processes) = proof_processes(&tripwire); + assert!(prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &processes).is_err()); + assert_eq!( + directory_digest_map(&fixture.repo.root.path().join("dist")), + before + ); + assert!(!tripwire.exists()); +} + +#[test] +fn prove_candidate_rejects_source_change_during_first_proof_run() { + let fixture = retained_fixture(); + let proofs = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs"); + let templates = tempfile::tempdir().unwrap(); + for id in PROOF_IDS { + fs::copy( + proofs.join(format!("{id}.json")), + templates.path().join(format!("{id}.json")), + ) + .unwrap(); + } + fs::remove_file(proofs.join("debian-amd64.json")).unwrap(); + let payload_before = directory_digest_map(&fixture.repo.root.path().join("dist/rust")); + let ledger_before = fixture.ledger_bytes.clone(); + let retained_before = + ["rpm-x86_64", "tar-x86_64"].map(|id| fs::read(proofs.join(format!("{id}.json"))).unwrap()); + let tripwire_dir = tempfile::tempdir().unwrap(); + let tripwire = tripwire_dir.path().join("build"); + let (_bin, processes) = proof_output_processes_with_source_mutation( + templates.path(), + &fixture.repo.root.path().join("Cargo.lock"), + &tripwire, + ); + assert!(prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &processes).is_err()); + assert!(proofs.join("debian-amd64.json").exists()); + for (id, bytes) in ["rpm-x86_64", "tar-x86_64"] + .into_iter() + .zip(retained_before) + { + assert_eq!(fs::read(proofs.join(format!("{id}.json"))).unwrap(), bytes); + } + assert_eq!( + directory_digest_map(&fixture.repo.root.path().join("dist/rust")), + payload_before + ); + assert_eq!( + fs::read( + fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json") + ) + .unwrap(), + ledger_before + ); + assert!(!tripwire.exists()); +} + +#[test] +fn prove_resume_preserves_payload_ledger_and_first_proof_without_rebuild() { + let fixture = retained_fixture(); + let proofs = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs"); + let templates = tempfile::tempdir().unwrap(); + for id in PROOF_IDS { + fs::copy( + proofs.join(format!("{id}.json")), + templates.path().join(format!("{id}.json")), + ) + .unwrap(); + fs::remove_file(proofs.join(format!("{id}.json"))).unwrap(); + } + let payload_before = directory_digest_map(&fixture.repo.root.path().join("dist/rust")); + let ledger_before = fixture.ledger_bytes.clone(); + let tripwire_dir = tempfile::tempdir().unwrap(); + let tripwire = tripwire_dir.path().join("build"); + let (_first_bin, first) = + proof_output_processes(templates.path(), Some("rpm-x86_64"), &tripwire); + assert!(prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &first).is_err()); + let first_proof = fs::read(proofs.join("debian-amd64.json")).unwrap(); + assert!(!proofs.join("rpm-x86_64.json").exists()); + let (_second_bin, second) = proof_output_processes(templates.path(), None, &tripwire); + let status = + prove_candidate(&fixture.repo.root, "1.0.0", &fixture.descriptor, &second).unwrap(); + assert_eq!(status.status, "candidate-proven"); + assert_eq!( + fs::read(proofs.join("debian-amd64.json")).unwrap(), + first_proof + ); + assert_eq!( + directory_digest_map(&fixture.repo.root.path().join("dist/rust")), + payload_before + ); + assert_eq!( + fs::read( + fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json") + ) + .unwrap(), + ledger_before + ); + assert!(!tripwire.exists()); +} + +#[test] +fn candidate_status_and_recovery_reject_on_disk_ledger_replacement() { + let fixture = retained_fixture(); + let path = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json"); + let mut value: Value = serde_json::from_slice(&fixture.ledger_bytes).unwrap(); + value["advisory_cohort"]["source_id"] = Value::String("replacement cohort".into()); + fs::write(&path, canonical_json(&value).unwrap()).unwrap(); + assert!(candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_err()); + assert!(recover_candidate(&fixture.repo.root, "1.0.0").is_err()); +} + +#[test] +fn create_readiness_ledger_replacement_rolls_back_promoted_candidate() { + let fixture = retained_fixture(); + let path = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json"); + let mut value: Value = serde_json::from_slice(&fixture.ledger_bytes).unwrap(); + value["advisory_cohort"]["source_id"] = Value::String("replacement cohort".into()); + fs::write(&path, canonical_json(&value).unwrap()).unwrap(); + let finalized = FinalizedCandidate { + ledger: fixture.ledger.clone(), + ledger_bytes: fixture.ledger_bytes.clone(), + payload_root: fixture.repo.root.path().join("dist/rust"), + evidence_root: fixture.repo.root.path().join("dist/rust-evidence/1.0.0"), + }; + let readiness = candidate_status( + &fixture.repo.root, + &finalized.ledger, + &finalized.ledger_bytes, + ); + let owned = PROOF_IDS.map(|id| { + finalized + .evidence_root + .join("proofs") + .join(format!("{id}.json")) + }); + assert!(finish_created_candidate(&finalized, &owned, readiness).is_err()); + assert!(!finalized.payload_root.exists()); + assert!(!finalized.evidence_root.exists()); +} + +#[test] +fn package_member_enumeration_propagates_entry_errors() { + let error = package_members_from_paths( + [Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "enumeration denied", + ))], + "1.0.0", + ) + .unwrap_err(); + assert!(error.to_string().contains("enumeration denied")); +} + +#[test] +fn candidate_staging_cleanup_is_owned_reported_and_sibling_safe() { + let fixture = retained_fixture(); + let staging_parent = fixture + .repo + .root + .path() + .join("dist/.rust-release-candidate-staging"); + fs::create_dir_all(&staging_parent).unwrap(); + let owned = staging_parent.join("owned"); + let sibling = staging_parent.join("foreign"); + fs::create_dir(&owned).unwrap(); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + finish_candidate_staging(&fixture.repo.root, "1.0.0", &owned, Ok(())).unwrap(); + assert!(!owned.exists()); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); + + let residue = staging_parent.join("residue"); + fs::write(&residue, b"not a directory").unwrap(); + let error = finish_candidate_staging::<()>( + &fixture.repo.root, + "1.0.0", + &residue, + Err(Error::new("primary failure")), + ) + .unwrap_err(); + assert!(error.to_string().contains("primary failure")); + assert!(error.to_string().contains("repair: remove")); + assert!(residue.exists()); + assert!(!fixture.repo.root.path().join("dist/rust").exists()); + assert!( + !fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0") + .exists() + ); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); +} + +#[test] +fn docker_identity_is_normalized_and_retained_validation_is_provider_neutral() { + let bin = tempfile::tempdir().unwrap(); + let docker = bin.path().join("docker"); + fs::write( + &docker, + "#!/bin/sh\necho 'Docker version 27.5.1, build fixture'\n", + ) + .unwrap(); + fs::set_permissions(&docker, fs::Permissions::from_mode(0o755)).unwrap(); + let processes = ProcessEnvironment::with_path(bin.path().as_os_str()); + assert_eq!( + observe_container_engine(&processes, ContainerEngine::Docker).unwrap(), + "docker 27.5.1" + ); + + let mut fixture = retained_fixture(); + fixture.ledger.images.engine = "docker".into(); + fixture.ledger.images.engine_version = "docker 27.5.1".into(); + fixture + .ledger + .tools + .insert("container_engine".into(), "docker 27.5.1".into()); + fixture.ledger_bytes = ledger_bytes( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &fixture.ledger, + ) + .unwrap(); + fs::write( + fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/ledger.json"), + &fixture.ledger_bytes, + ) + .unwrap(); + for id in PROOF_IDS { + let path = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs") + .join(format!("{id}.json")); + fs::remove_file(path).unwrap(); + write_valid_proof( + &fixture.repo.root, + &fixture.ledger, + &fixture.ledger_bytes, + id, + ); + } + assert!(candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_ok()); + assert_eq!( + recover_candidate(&fixture.repo.root, "1.0.0").unwrap(), + "retained-candidate-valid" + ); + for invalid in ["Docker 27.5.1", "docker 27.5.2", "DOCKER 27.5.1"] { + fixture.ledger.images.engine_version = invalid.into(); + fixture + .ledger + .tools + .insert("container_engine".into(), invalid.into()); + assert!( + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_err() + ); + } +} + +#[test] +fn git_commit_identity_accepts_sha1_and_sha256_only() { + for valid in ["a".repeat(40), "b".repeat(64)] { + require_commit(&valid, "commit").unwrap(); + } + for invalid in [ + "a".repeat(39), + "a".repeat(41), + "a".repeat(63), + "a".repeat(65), + "A".repeat(40), + format!("{}g", "a".repeat(39)), + format!("{}\n", "a".repeat(40)), + format!(" {}", "a".repeat(40)), + ] { + assert!( + require_commit(&invalid, "commit").is_err(), + "accepted {invalid:?}" + ); + } + let fixture = retained_fixture(); + let mut ledger = fixture.ledger.clone(); + ledger.source.commit = "a".repeat(64); + ledger.advisory_cohort.commit = "b".repeat(64); + assert!( + ledger_bytes( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + &ledger + ) + .is_ok() + ); + let mut proof = valid_proof(); + proof["source_commit"] = Value::String("c".repeat(64)); + let mut expected = proof_bindings(); + expected.source_commit = "c".repeat(64); + validate_candidate_proof(&proof, &expected).unwrap(); +} + +#[test] +fn sha256_git_fixture_flows_through_context_lane_status_and_recovery() { + let repo = crate::candidate_tests::sha256_fixture(); + assert_eq!(repo.commit.len(), 64); + require_expected_commit(&repo.root, &repo.commit).unwrap(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let staging = StagingLayout::create(&repo.root, &lock).unwrap(); + let context = export_immutable_context(&repo.root, &staging.context).unwrap(); + assert_eq!(context.commit, repo.commit); + let policy = ReleaseImages::from_context(&context).unwrap(); + let ubuntu = proof_image_identity(&policy.build_ubuntu); + let fedora = proof_image_identity(&policy.build_fedora); + let lane = crate::candidate_tests::lane_fixture( + &staging.deb_lane, + Lane::Deb, + &context, + "0123456789abcdef0123456789abcdef", + &ubuntu.digest, + b"same tar", + ); + validate_lane_evidence( + &lane, + &LaneRequest { + repo: &repo.root, + context: &context, + lane: Lane::Deb, + engine: ContainerEngine::Podman, + invocation_id: "0123456789abcdef0123456789abcdef", + version: "1.0.0", + ubuntu: &ubuntu, + fedora: &fedora, + output: &staging.deb_lane, + processes: &ProcessEnvironment::default(), + }, + ) + .unwrap(); + fs::remove_dir_all(&staging.root).unwrap(); + drop(lock); + + let retained = retained_fixture_from(repo); + assert_eq!(retained.ledger.source.commit.len(), 64); + assert!( + candidate_status( + &retained.repo.root, + &retained.ledger, + &retained.ledger_bytes + ) + .is_ok() + ); + assert_eq!( + recover_candidate(&retained.repo.root, "1.0.0").unwrap(), + "retained-candidate-valid" + ); +} + +fn docker_create_templates(root: &RepoRoot, directory: &Path) { + let products = crate::tests::release_fixture(); + for entry in fs::read_dir(products.path()).unwrap() { + let entry = entry.unwrap(); + fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); + } + let policy = ReleaseImages::from_root(root.path()).unwrap(); + for (lane, image, name) in [ + (Lane::Deb, &policy.build_ubuntu, "deb-lane.json"), + (Lane::Rpm, &policy.build_fedora, "rpm-lane.json"), + ] { + let native = match lane { + Lane::Deb => "solstone-linux_1.0.0-1_amd64.deb", + Lane::Rpm => "solstone-linux-1.0.0-1.x86_64.rpm", + }; + let evidence = LaneEvidence { + invocation_id: "@INVOCATION@".into(), + lane, + source_commit: "@SOURCE_COMMIT@".into(), + source_archive_sha256: "@ARCHIVE@".into(), + cargo_lock_sha256: "@LOCK@".into(), + version: "1.0.0".into(), + target: TARGET_TRIPLE.into(), + profile: "release".into(), + features: vec![], + rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), + cargo: "cargo 1.97.1 (abcdef012 2026-06-30)".into(), + baseline_executable_sha256: "d".repeat(64), + image_digest: "@IMAGE@".into(), + packaging_tool: match lane { + Lane::Deb => "cargo-deb 3.7.0", + Lane::Rpm => "cargo-generate-rpm 0.21.0", + } + .into(), + native_tools: crate::candidate_tests::lane_tools( + lane, + &proof_image_identity(image).digest, + ), + artifacts: ["solstone-linux-1.0.0-linux-x86_64.tar.gz", native] + .iter() + .map(|path| artifact(&directory.join(path)).unwrap()) + .collect(), + }; + fs::write(directory.join(name), serde_json::to_vec(&evidence).unwrap()).unwrap(); + } + for id in PROOF_IDS { + let platform = policy.proof_policy(id).unwrap(); + let artifact_name = match id { + "debian-amd64" => "solstone-linux_1.0.0-1_amd64.deb", + "rpm-x86_64" => "solstone-linux-1.0.0-1.x86_64.rpm", + _ => "solstone-linux-1.0.0-linux-x86_64.tar.gz", + }; + let artifact_record = artifact(&directory.join(artifact_name)).unwrap(); + let member = package_member_evidence(&directory.join(artifact_name), "1.0.0").unwrap(); + let proof = CandidateProof { + schema_version: 1, + platform: id.into(), + candidate_digest: "@CANDIDATE@".into(), + ledger_sha256: "@LEDGER@".into(), + source_commit: "@SOURCE_COMMIT@".into(), + cargo_lock_sha256: "@LOCK@".into(), + artifact_basename: artifact_name.into(), + artifact_bytes: artifact_record.bytes, + artifact_sha256: artifact_record.sha256, + proof_image_digest: "@IMAGE@".into(), + os_release: platform.os_release.clone(), + package_manager_version: platform.package_manager_version.clone(), + install_command: platform.install_command.clone(), + install_exit_status: 0, + version_command: platform.version_command.clone(), + version_exit_status: 0, + executable_path: platform.executable_path.clone(), + executable_mode: platform.executable_mode, + executable_sha256: member.sha256, + version_output: "solstone-linux 1.0.0".into(), + result: "pass".into(), + proof_time: "@PROOF_TIME@".into(), + architecture: if id == "debian-amd64" { + "amd64" + } else { + "x86_64" + } + .into(), + network: "none".into(), + isolation: "fresh-container".into(), + dry_run_passed: (id == "tar-x86_64").then_some(true), + isolated_prefix_passed: (id == "tar-x86_64").then_some(true), + }; + fs::write( + directory.join(format!("{id}.json")), + canonical_json(&serde_json::to_value(proof).unwrap()).unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn docker_create_candidate_is_offline_normalized_and_recoverable() { + let repo = crate::candidate_tests::fixture(); + let db = crate::candidate_tests::git_repo(); + let descriptor_dir = tempfile::tempdir().unwrap(); + let descriptor = crate::candidate_tests::descriptor( + descriptor_dir.path(), + db.path(), + None, + &crate::candidate_tests::current_time(), + ); + let stubs = tempfile::tempdir().unwrap(); + let templates = tempfile::tempdir().unwrap(); + docker_create_templates(&repo.root, templates.path()); + let fail_producer = stubs.path().join("fail-producer"); + let fail_validator = stubs.path().join("fail-validator"); + let mutate_source = stubs.path().join("mutate-source"); + let mutate_lock_digest = stubs.path().join("mutate-lock-digest"); + let mutate_cohort = stubs.path().join("mutate-cohort"); + fs::write( + stubs.path().join("git"), + "#!/bin/sh\nexec /usr/bin/git \"$@\"\n", + ) + .unwrap(); + fs::write(stubs.path().join("cargo"), "#!/bin/sh\nexit 0\n").unwrap(); + let forbidden = stubs.path().join("forbidden"); + let docker = format!( + r#"#!/bin/sh +case "$1" in + --version) echo 'Docker version 27.5.1, build fixture'; exit 0;; + login|logout|pull|tag|push) printf '%s' "$*" > '{forbidden}'; exit 90;; +esac +if [ "$1" = buildx ] && [ "$2" = version ]; then echo 'github.com/docker/buildx v0.20.0'; exit 0; fi +if [ "$1" = image ] && [ "$2" = inspect ]; then + id=${{3##*sha256:}} + printf '[{{"Id":"sha256:%s","Os":"linux","Architecture":"amd64"}}]' "$id" + exit 0 +fi +if [ "$1" = buildx ] && [ "$2" = build ]; then + pull=0; network=0; output=; target=; invocation=; commit=; archive=; lock=; ubuntu=; fedora= + previous= + for argument in "$@"; do + [ "$argument" = --pull=false ] && pull=1 + [ "$argument" = --network=none ] && network=1 + [ "$previous" = --output ] && output=${{argument#type=local,dest=}} + [ "$previous" = --target ] && target=$argument + case "$argument" in + INVOCATION_ID=*) invocation=${{argument#*=}};; SOURCE_COMMIT=*) commit=${{argument#*=}};; + SOURCE_ARCHIVE_SHA256=*) archive=${{argument#*=}};; CARGO_LOCK_SHA256=*) lock=${{argument#*=}};; + UBUNTU_TOOL_BASE=*) ubuntu=${{argument#*=}};; FEDORA_TOOL_BASE=*) fedora=${{argument#*=}};; + esac + previous=$argument + done + [ "$pull" = 1 ] && [ "$network" = 1 ] || exit 91 + case "$target" in + deb) native=solstone-linux_1.0.0-1_amd64.deb; template=deb-lane.json; image=$ubuntu;; + rpm) native=solstone-linux-1.0.0-1.x86_64.rpm; template=rpm-lane.json; image=$fedora;; + *) exit 92;; + esac + /bin/cp '{templates}/solstone-linux-1.0.0-linux-x86_64.tar.gz' "$output/" + /bin/cp "{templates}/$native" "$output/" + /bin/sed -e "s#@INVOCATION@#$invocation#g" -e "s#@SOURCE_COMMIT@#$commit#g" -e "s#@ARCHIVE@#$archive#g" -e "s#@LOCK@#$lock#g" -e "s#@IMAGE@#$image#g" "{templates}/$template" > "$output/.lane-evidence-handoff.json" + exit 0 +fi +if [ "$1" = run ]; then + network=0; pull=0; output=; platform=; candidate=; ledger=; commit=; lock=; image=; previous= + for argument in "$@"; do + [ "$argument" = --network=none ] && network=1 + [ "$argument" = --pull=never ] && pull=1 + case "$argument" in type=bind,src=*,dst=/evidence) output=${{argument#type=bind,src=}}; output=${{output%,dst=/evidence}};; esac + [ "$previous" = --platform ] && platform=$argument + [ "$previous" = --candidate-digest ] && candidate=$argument + [ "$previous" = --ledger-sha256 ] && ledger=$argument + [ "$previous" = --source-commit ] && commit=$argument + [ "$previous" = --cargo-lock-sha256 ] && lock=$argument + [ "$previous" = --proof-image-digest ] && image=$argument + previous=$argument + done + [ "$network" = 1 ] && [ "$pull" = 1 ] || exit 93 + [ -f '{fail_producer}' ] && [ "$(/bin/cat '{fail_producer}')" = "$platform" ] && exit 97 + proof_time=$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ) + /bin/sed -e "s#@CANDIDATE@#$candidate#g" -e "s#@LEDGER@#$ledger#g" -e "s#@SOURCE_COMMIT@#$commit#g" -e "s#@LOCK@#$lock#g" -e "s#@IMAGE@#$image#g" -e "s#@PROOF_TIME@#$proof_time#g" "{templates}/$platform.json" > "$output/proof.json" + [ -f '{fail_validator}' ] && [ "$(/bin/cat '{fail_validator}')" = "$platform" ] && /usr/bin/printf ' ' >> "$output/proof.json" + [ -f '{mutate_source}' ] && [ "$platform" = tar-x86_64 ] && /usr/bin/printf 'mid-create source drift\n' > Cargo.lock + if [ -f '{mutate_lock_digest}' ] && [ "$platform" = tar-x86_64 ]; then + /usr/bin/printf 'mid-create hidden lock drift\n' > Cargo.lock + /usr/bin/git update-index --assume-unchanged Cargo.lock + fi + [ -f '{mutate_cohort}' ] && [ "$platform" = tar-x86_64 ] && /usr/bin/printf dirty > '{advisory_db}/DIRTY' + exit 0 +fi +printf '%s' "$*" > '{forbidden}' +exit 94 +"#, + forbidden = forbidden.display(), + templates = templates.path().display(), + fail_producer = fail_producer.display(), + fail_validator = fail_validator.display(), + mutate_source = mutate_source.display(), + mutate_lock_digest = mutate_lock_digest.display(), + mutate_cohort = mutate_cohort.display(), + advisory_db = db.path().display(), + ); + fs::write(stubs.path().join("docker"), docker).unwrap(); + for name in ["git", "cargo", "docker"] { + fs::set_permissions(stubs.path().join(name), fs::Permissions::from_mode(0o755)).unwrap(); + } + let processes = ProcessEnvironment::with_path(stubs.path().as_os_str()); + for (marker, class) in [(&fail_producer, "producer"), (&fail_validator, "validator")] { + for id in PROOF_IDS { + fs::write(marker, id).unwrap(); + let failed = crate::candidate_tests::fixture(); + let descriptor_dir = tempfile::tempdir().unwrap(); + let failed_descriptor = crate::candidate_tests::descriptor( + descriptor_dir.path(), + db.path(), + None, + &crate::candidate_tests::current_time(), + ); + let sibling = failed + .root + .path() + .join("dist/.rust-release-candidate-staging/foreign"); + fs::create_dir_all(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + let error = + create_candidate(&failed.root, &failed.commit, &failed_descriptor, &processes) + .unwrap_err(); + assert!(!error.to_string().contains("candidate-proven")); + assert!( + !failed.root.path().join("dist/rust").exists(), + "{class} {id}" + ); + assert!( + !failed.root.path().join("dist/rust-evidence/1.0.0").exists(), + "{class} {id}" + ); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); + let owned = fs::read_dir(sibling.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(owned, vec![std::ffi::OsString::from("foreign")]); + } + fs::remove_file(marker).unwrap(); + } + for (marker, class) in [ + (&mutate_source, "source"), + (&mutate_lock_digest, "lock digest"), + (&mutate_cohort, "cohort"), + ] { + fs::write(marker, b"enabled").unwrap(); + let failed = crate::candidate_tests::fixture(); + let descriptor_dir = tempfile::tempdir().unwrap(); + let failed_descriptor = crate::candidate_tests::descriptor( + descriptor_dir.path(), + db.path(), + None, + &crate::candidate_tests::current_time(), + ); + let sibling = failed + .root + .path() + .join("dist/.rust-release-candidate-staging/foreign"); + fs::create_dir_all(&sibling).unwrap(); + fs::write(sibling.join("canary"), b"foreign").unwrap(); + let error = create_candidate(&failed.root, &failed.commit, &failed_descriptor, &processes) + .unwrap_err(); + assert!(!error.to_string().contains("candidate-proven")); + assert!(!failed.root.path().join("dist/rust").exists(), "{class}"); + assert!( + !failed.root.path().join("dist/rust-evidence/1.0.0").exists(), + "{class}" + ); + assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); + let staging_inventory = fs::read_dir(sibling.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(staging_inventory, vec![std::ffi::OsString::from("foreign")]); + fs::remove_file(marker).unwrap(); + if class == "cohort" { + fs::remove_file(db.path().join("DIRTY")).unwrap(); + } + } + let status = create_candidate(&repo.root, &repo.commit, &descriptor, &processes).unwrap(); + assert_eq!(status.status, "candidate-proven"); + assert!(!forbidden.exists()); + let (ledger, bytes) = read_ledger(&repo.root, "1.0.0").unwrap(); + assert_eq!(ledger.images.engine, "docker"); + assert_eq!(ledger.images.engine_version, "docker 27.5.1"); + assert_eq!(ledger.tools["container_engine"], "docker 27.5.1"); + assert!(candidate_status(&repo.root, &ledger, &bytes).is_ok()); + assert_eq!( + recover_candidate(&repo.root, "1.0.0").unwrap(), + "retained-candidate-valid" + ); +} diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 0851be9..1e024b9 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -7,7 +7,43 @@ use flate2::write::GzEncoder; use std::io::Write; use std::os::unix::fs::symlink; -fn tools() -> BTreeMap { +fn validate_manifest_bytes(bytes: &[u8]) -> Result { + let repo = crate::candidate_tests::fixture(); + super::validate_manifest_bytes(&repo.root, bytes) +} + +fn render_manifest(evidence: Evidence, release_dir: &Path) -> Result { + let repo = crate::candidate_tests::fixture(); + super::render_manifest(&repo.root, evidence, release_dir) +} + +fn verify_manifest(path: &Path, bind_live: bool) -> Result<()> { + let repo = crate::candidate_tests::fixture(); + super::verify_manifest(&repo.root, path, bind_live) +} + +fn classify_release(path: &Path, bind_live: bool) -> Result<()> { + let repo = crate::candidate_tests::fixture(); + super::classify_release(&repo.root, path, bind_live) +} + +fn classify_release_dir(path: &Path) -> Result<()> { + let repo = crate::candidate_tests::fixture(); + super::classify_release_dir(&repo.root, path) +} + +pub(super) fn write_rendered(evidence: Evidence, release_dir: &Path) -> Result<()> { + let text = render_manifest(evidence, release_dir)?; + let manifest = validate_manifest_bytes(text.as_bytes())?; + fs::write(release_dir.join(manifest_name(&manifest.version)), text).map_err(display_error)?; + fs::write( + release_dir.join(CHECKSUM_NAME), + render_sha256sums(&manifest.artifacts)?, + ) + .map_err(display_error) +} + +pub(super) fn tools() -> BTreeMap { BTreeMap::from([ ("container_engine".into(), "podman 5.4.0".into()), ("ubuntu_image_digest".into(), "a".repeat(64)), @@ -33,8 +69,9 @@ fn tools() -> BTreeMap { ]) } -fn evidence() -> Evidence { - let root = workspace_root().unwrap(); +pub(super) fn evidence() -> Evidence { + let repo = crate::candidate_tests::fixture(); + let root = repo.root.path(); let version: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml")).unwrap()).unwrap(); Evidence { @@ -44,7 +81,7 @@ fn evidence() -> Evidence { .as_str() .unwrap() .into(), - source_commit: command(&root, &["git", "rev-parse", "HEAD"]).unwrap(), + source_commit: command(root, &["git", "rev-parse", "HEAD"]).unwrap(), source_dirty: false, cargo_lock_sha256: digest(&fs::read(root.join("Cargo.lock")).unwrap()), rust: RustEvidence { @@ -210,6 +247,19 @@ fn control_tar_bodies(bodies: &[String]) -> Vec { archive.into_inner().unwrap() } +fn data_tar() -> Vec { + let mut archive = tar::Builder::new(Vec::new()); + let bytes = b"fixture executable"; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive + .append_data(&mut header, "./usr/bin/solstone-linux", &bytes[..]) + .unwrap(); + archive.into_inner().unwrap() +} + fn gzip(bytes: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(bytes).unwrap(); @@ -237,19 +287,45 @@ fn deb(root: &Path, version: &str) -> PathBuf { let compressed = encoder.finish().unwrap(); let header = ar::Header::new(b"control.tar.gz".to_vec(), compressed.len() as u64); archive.append(&header, &compressed[..]).unwrap(); + let compressed = gzip(&data_tar()); + let header = ar::Header::new(b"data.tar.gz".to_vec(), compressed.len() as u64); + archive.append(&header, &compressed[..]).unwrap(); path } fn rpm_file(root: &Path, version: &str) -> PathBuf { let path = root.join(format!("solstone-linux-{version}-1.x86_64.rpm")); let package = rpm::PackageBuilder::new(PRODUCT, version, "AGPL-3.0-only", "x86_64", "fixture") + .with_file_contents( + b"fixture executable".as_slice(), + rpm::FileOptions::new("/usr/bin/solstone-linux").permissions(0o755), + ) + .unwrap() .build() .unwrap(); package.write(&mut File::create(&path).unwrap()).unwrap(); path } -fn release_fixture() -> tempfile::TempDir { +#[test] +fn package_member_evidence_is_bound_to_all_three_formats() { + let fixture = release_fixture(); + for path in artifact_paths(fixture.path(), "1.0.0").unwrap() { + let member = package_member_evidence(&path, "1.0.0").unwrap(); + assert_eq!(member.mode, 0o755); + assert_eq!( + member.sha256, + digest(if member.format == "tar" { + b"fixture" + } else { + b"fixture executable" + }) + ); + assert!(member.installed_path.ends_with("/bin/solstone-linux")); + } +} + +pub(super) fn release_fixture() -> tempfile::TempDir { let temp = tempfile::tempdir().unwrap(); let version = evidence().version; producer_tarball(temp.path(), &version); @@ -261,9 +337,8 @@ fn release_fixture() -> tempfile::TempDir { #[test] fn rust_release_manifest_conformance() { verify_schema().unwrap(); - let vendor = workspace_root() - .unwrap() - .join("vendor/rust-release-manifest"); + let repo = crate::candidate_tests::fixture(); + let vendor = repo.root.path().join("vendor/rust-release-manifest"); let entries = fs::read_dir(&vendor) .unwrap() .map(|entry| entry.unwrap().file_name().into_string().unwrap()) @@ -274,8 +349,8 @@ fn rust_release_manifest_conformance() { ); let descriptor: Value = serde_json::from_slice( &fs::read( - workspace_root() - .unwrap() + repo.root + .path() .join("contracts/rust-release-manifest-import.json"), ) .unwrap(), @@ -1049,8 +1124,9 @@ fn rendered_manifest() -> (tempfile::TempDir, Manifest) { #[test] fn live_semantic_drift_is_rejected_field_by_field() { let (outside, manifest) = rendered_manifest(); + let repo = crate::candidate_tests::fixture(); let reject = |candidate: &Manifest| { - assert!(validate_live(candidate, outside.path()).is_err()); + assert!(validate_live(&repo.root, candidate, outside.path()).is_err()); }; let mut candidate = manifest.clone(); diff --git a/crates/rust-release-manifest/src/transaction.rs b/crates/rust-release-manifest/src/transaction.rs new file mode 100644 index 0000000..92889da --- /dev/null +++ b/crates/rust-release-manifest/src/transaction.rs @@ -0,0 +1,1584 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +pub const PROOF_IDS: [&str; 3] = ["debian-amd64", "rpm-x86_64", "tar-x86_64"]; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateLedger { + pub schema_version: u64, + pub product: String, + pub version: String, + pub source: LedgerSource, + pub validator: LedgerValidator, + pub target: LedgerTarget, + pub policy: LedgerPolicy, + pub advisory_cohort: LedgerAdvisory, + pub images: LedgerImages, + pub tools: BTreeMap, + pub payload: Vec, + pub package_members: Vec, + pub expected_proof_ids: Vec, + pub candidate_digest: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerSource { + pub commit: String, + pub archive_sha256: String, + pub cargo_lock_sha256: String, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerValidator { + pub version: String, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerTarget { + pub triple: String, + pub profile: String, + pub features: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerPolicy { + pub cargo_deny_version: String, + pub deterministic_gate: String, + pub licenses_bans_sources: String, + pub advisories: String, + pub checked_at: String, + pub active_exceptions: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerAdvisory { + pub source_id: String, + pub commit: String, + pub archive_sha256: String, + pub acquired_at: String, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerImages { + pub engine: String, + pub engine_version: String, + pub ubuntu_image_id: String, + pub fedora_image_id: String, +} + +pub struct LedgerInput<'a> { + pub root: &'a RepoRoot, + pub context: &'a ImmutableContext, + pub version: &'a str, + pub payload_root: &'a Path, + pub package_members: Vec, + pub cohort: &'a AdvisoryCohort, + pub ubuntu: &'a ImageIdentity, + pub fedora: &'a ImageIdentity, + pub engine: ContainerEngine, + pub engine_identity: String, + pub tools: BTreeMap, +} + +pub fn construct_ledger(input: LedgerInput<'_>) -> Result { + let mut payload = fs::read_dir(input.payload_root) + .map_err(display_error)? + .map(|entry| artifact(&entry.map_err(display_error)?.path())) + .collect::>>()?; + payload.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes())); + if payload.len() != 5 { + return Err(Error::new(format!( + "ledger payload mismatch: expected 5 files, actual {}", + payload.len() + ))); + } + let candidate = candidate_digest(&payload)?; + let active_exceptions = ordered_exceptions(input.root)?; + let mut members = input.package_members; + members.sort_by(|a, b| a.package_file.as_bytes().cmp(b.package_file.as_bytes())); + let ledger = CandidateLedger { + schema_version: 1, + product: PRODUCT.into(), + version: input.version.into(), + source: LedgerSource { + commit: input.context.commit.clone(), + archive_sha256: input.context.archive_sha256.clone(), + cargo_lock_sha256: input.context.cargo_lock_sha256.clone(), + }, + validator: LedgerValidator { + version: env!("CARGO_PKG_VERSION").into(), + }, + target: LedgerTarget { + triple: TARGET_TRIPLE.into(), + profile: "release".into(), + features: Vec::new(), + }, + policy: LedgerPolicy { + cargo_deny_version: input.cohort.cargo_deny_version.clone(), + deterministic_gate: input.cohort.deterministic_gate.clone(), + licenses_bans_sources: input.cohort.licenses_bans_sources.clone(), + advisories: input.cohort.advisories.clone(), + checked_at: input.cohort.checked_at.clone(), + active_exceptions, + }, + advisory_cohort: LedgerAdvisory { + source_id: input.cohort.source_id.clone(), + commit: input.cohort.commit.clone(), + archive_sha256: input.cohort.archive_sha256.clone(), + acquired_at: input.cohort.acquired_at.clone(), + }, + images: LedgerImages { + engine: match input.engine { + ContainerEngine::Podman => "podman", + ContainerEngine::Docker => "docker", + } + .into(), + engine_version: input.engine_identity, + ubuntu_image_id: input.ubuntu.digest.clone(), + fedora_image_id: input.fedora.digest.clone(), + }, + tools: input.tools, + payload, + package_members: members, + expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + candidate_digest: candidate, + }; + validate_ledger(input.root, input.payload_root, &ledger)?; + Ok(ledger) +} + +pub fn ledger_bytes( + root: &RepoRoot, + payload_root: &Path, + ledger: &CandidateLedger, +) -> Result> { + // Schema and privacy validation precede serialization, so canonicalization + // cannot discover a ledger-shape failure at the promotion boundary. + validate_ledger(root, payload_root, ledger)?; + canonical_json(&serde_json::to_value(ledger).map_err(display_error)?) +} + +pub fn validate_ledger( + root: &RepoRoot, + payload_root: &Path, + ledger: &CandidateLedger, +) -> Result<()> { + verify_candidate_schemas()?; + let value = serde_json::to_value(ledger).map_err(display_error)?; + let schema: Value = serde_json::from_slice(ledger_schema_bytes()).map_err(display_error)?; + let validator = jsonschema::draft202012::options() + .should_validate_formats(true) + .build(&schema) + .map_err(display_error)?; + if let Err(error) = validator.validate(&value) { + return Err(Error::new(format!("ledger schema mismatch: {error}"))); + } + validate_version(&ledger.version)?; + validate_timestamp(&ledger.policy.checked_at)?; + validate_timestamp(&ledger.advisory_cohort.acquired_at)?; + validate_evidence_text("ledger advisory source", &ledger.advisory_cohort.source_id)?; + validate_native_tools(root, &ledger.tools)?; + if ledger.validator.version != env!("CARGO_PKG_VERSION") { + return Err(Error::new(format!( + "ledger validator mismatch: expected {}, actual {}", + env!("CARGO_PKG_VERSION"), + ledger.validator.version + ))); + } + let ubuntu = ledger + .images + .ubuntu_image_id + .strip_prefix("sha256:") + .unwrap_or_default(); + let fedora = ledger + .images + .fedora_image_id + .strip_prefix("sha256:") + .unwrap_or_default(); + let expected_engine = if ledger.images.engine_version.starts_with("podman version ") { + "podman" + } else if ledger.images.engine_version.starts_with("docker ") { + "docker" + } else { + "" + }; + if ledger.tools.get("ubuntu_image_digest").map(String::as_str) != Some(ubuntu) + || ledger.tools.get("fedora_image_digest").map(String::as_str) != Some(fedora) + || ledger.tools.get("container_engine").map(String::as_str) + != Some(ledger.images.engine_version.as_str()) + || ledger.images.engine != expected_engine + { + return Err(Error::new( + "ledger image identity mismatch: expected tool evidence, actual different", + )); + } + let mut actual = ledger + .payload + .iter() + .map(|item| artifact(&payload_root.join(&item.path))) + .collect::>>()?; + actual.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes())); + if actual != ledger.payload || candidate_digest(&actual)? != ledger.candidate_digest { + return Err(Error::new( + "ledger payload digest mismatch: expected promoted bytes, actual different", + )); + } + if ledger.package_members.len() != 3 || ledger.expected_proof_ids != PROOF_IDS { + return Err(Error::new("ledger evidence inventory mismatch")); + } + let mut members = package_members(payload_root, &ledger.version)?; + members.sort_by(|a, b| a.package_file.as_bytes().cmp(b.package_file.as_bytes())); + if members != ledger.package_members { + return Err(Error::new( + "ledger package member mismatch: expected package bytes, actual different", + )); + } + Ok(()) +} + +pub fn read_ledger(root: &RepoRoot, version: &str) -> Result<(CandidateLedger, Vec)> { + let path = root + .path() + .join("dist/rust-evidence") + .join(version) + .join("ledger.json"); + require_regular(&path, "candidate ledger")?; + let bytes = fs::read(&path).map_err(display_error)?; + let ledger: CandidateLedger = serde_json::from_slice(&bytes).map_err(display_error)?; + if canonical_json(&serde_json::to_value(&ledger).map_err(display_error)?)? != bytes { + return Err(Error::new("ledger canonicalization mismatch")); + } + validate_ledger(root, &root.path().join("dist/rust"), &ledger)?; + Ok((ledger, bytes)) +} + +pub fn atomic_write_0644(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| Error::new("atomic output parent mismatch"))?; + require_directory(parent, "atomic output parent")?; + if path.symlink_metadata().is_ok() { + return Err(Error::new(format!( + "atomic output mismatch: expected absent, actual {}", + path.display() + ))); + } + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().and_then(OsStr::to_str).unwrap_or("output"), + transaction_id()? + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(&temp) + .map_err(display_error)?; + let publish = (|| { + file.write_all(bytes).map_err(display_error)?; + file.sync_all().map_err(display_error)?; + fs::set_permissions(&temp, fs::Permissions::from_mode(0o644)).map_err(display_error)?; + fs::rename(&temp, path).map_err(display_error) + })(); + drop(file); + finish_atomic_publish(&temp, publish)?; + File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(display_error) +} + +pub(crate) fn finish_atomic_publish(temp: &Path, publish: Result<()>) -> Result<()> { + if let Err(error) = publish { + if temp.symlink_metadata().is_ok() { + fs::remove_file(temp).map_err(|cleanup| { + Error::new(format!( + "{error}\nerror: atomic output cleanup mismatch: expected owned temporary absent, actual residue\nrepair: remove the failed transaction temporary file from {}: {cleanup}", + temp.parent().unwrap_or_else(|| Path::new(".")).display() + )) + })?; + } + return Err(error); + } + Ok(()) +} + +pub struct FinalizeInput<'a> { + pub root: &'a RepoRoot, + pub staging: &'a StagingLayout, + pub context: &'a ImmutableContext, + pub version: &'a str, + pub deb: &'a LaneEvidence, + pub rpm: &'a LaneEvidence, + pub cohort: &'a AdvisoryCohort, + pub images: &'a ResolvedImages, + pub engine: ContainerEngine, + pub engine_identity: String, + pub processes: &'a ProcessEnvironment, +} + +pub struct FinalizedCandidate { + pub ledger: CandidateLedger, + pub ledger_bytes: Vec, + pub payload_root: PathBuf, + pub evidence_root: PathBuf, +} + +pub fn finalize_candidate(input: FinalizeInput<'_>) -> Result { + reconcile_lanes( + input.deb, + input.rpm, + &input.staging.deb_lane, + &input.staging.rpm_lane, + )?; + stage_payload(&input)?; + classify_release(input.root, &input.staging.payload, false)?; + recheck_source(input.root, input.context, &input.staging.payload)?; + recheck_images( + input.processes, + input.engine, + [&input.images.build_ubuntu, &input.images.build_fedora], + )?; + recheck_advisory_cohort(input.cohort, input.processes, Utc::now())?; + let payload_root = input.root.path().join("dist/rust"); + if payload_root.symlink_metadata().is_ok() { + return Err(Error::new( + "candidate payload mismatch: expected absent before promotion, actual present", + )); + } + fs::rename(&input.staging.payload, &payload_root).map_err(display_error)?; + let evidence_root = input + .root + .path() + .join("dist/rust-evidence") + .join(input.version); + let result = (|| { + classify_release(input.root, &payload_root, false)?; + let members = package_members(&payload_root, input.version)?; + let tools = assemble_manifest_native_tools( + input.root, + input.deb, + input.rpm, + input.engine_identity.clone(), + )?; + let ledger = construct_ledger(LedgerInput { + root: input.root, + context: input.context, + version: input.version, + payload_root: &payload_root, + package_members: members, + cohort: input.cohort, + ubuntu: &input.images.build_ubuntu, + fedora: &input.images.build_fedora, + engine: input.engine, + engine_identity: input.engine_identity, + tools, + })?; + let bytes = ledger_bytes(input.root, &payload_root, &ledger)?; + fs::create_dir_all(&evidence_root).map_err(display_error)?; + let ledger_path = evidence_root.join("ledger.json"); + atomic_write_0644(&ledger_path, &bytes)?; + recheck_source(input.root, input.context, &payload_root)?; + recheck_images( + input.processes, + input.engine, + [&input.images.build_ubuntu, &input.images.build_fedora], + )?; + recheck_advisory_cohort(input.cohort, input.processes, Utc::now())?; + Ok(FinalizedCandidate { + ledger, + ledger_bytes: bytes, + payload_root: payload_root.clone(), + evidence_root: evidence_root.clone(), + }) + })(); + match result { + Ok(value) => Ok(value), + Err(error) => Err(rollback_error(error, &payload_root, &evidence_root, &[])), + } +} + +fn stage_payload(input: &FinalizeInput<'_>) -> Result<()> { + require_directory(&input.staging.payload, "staged payload")?; + if fs::read_dir(&input.staging.payload) + .map_err(display_error)? + .next() + .is_some() + { + return Err(Error::new( + "staged payload mismatch: expected empty, actual populated", + )); + } + let tar = input + .deb + .artifacts + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some("tar")) + .ok_or_else(|| Error::new("deb tar mismatch"))?; + let deb = input + .deb + .artifacts + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some("deb")) + .ok_or_else(|| Error::new("deb artifact mismatch"))?; + let rpm = input + .rpm + .artifacts + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some("rpm")) + .ok_or_else(|| Error::new("rpm artifact mismatch"))?; + for (source_root, item) in [ + (&input.staging.deb_lane, tar), + (&input.staging.deb_lane, deb), + (&input.staging.rpm_lane, rpm), + ] { + let destination = input.staging.payload.join(&item.path); + fs::copy(source_root.join(&item.path), &destination).map_err(display_error)?; + fs::set_permissions(&destination, fs::Permissions::from_mode(0o644)) + .map_err(display_error)?; + } + let tools = assemble_manifest_native_tools( + input.root, + input.deb, + input.rpm, + input.engine_identity.clone(), + )?; + let evidence = Evidence { + schema_version: SCHEMA_VERSION, + product: PRODUCT.into(), + version: input.version.into(), + source_commit: input.context.commit.clone(), + source_dirty: false, + cargo_lock_sha256: input.context.cargo_lock_sha256.clone(), + rust: RustEvidence { + rustc_verbose: input.deb.rustc_verbose.clone(), + cargo_version: input.deb.cargo.clone(), + }, + target: TargetEvidence::Compiled { + triple: input.deb.target.clone(), + profile: input.deb.profile.clone(), + features: input.deb.features.clone(), + }, + native_tools: tools, + dependency_policy: DependencyPolicy { + cargo_deny_version: input.cohort.cargo_deny_version.clone(), + deterministic_gate: input.cohort.deterministic_gate.clone(), + advisory_checked_at: input.cohort.checked_at.clone(), + }, + active_exceptions: ordered_exceptions(input.root)?, + }; + let manifest = render_manifest(input.root, evidence, &input.staging.payload)?; + let parsed: Manifest = serde_json::from_str(&manifest).map_err(display_error)?; + let sums = render_sha256sums(&parsed.artifacts)?; + atomic_write_0644(&input.staging.payload.join(CHECKSUM_NAME), sums.as_bytes())?; + atomic_write_0644( + &input.staging.payload.join(manifest_name(input.version)), + manifest.as_bytes(), + )?; + for entry in fs::read_dir(&input.staging.payload).map_err(display_error)? { + let path = entry.map_err(display_error)?.path(); + File::open(&path) + .and_then(|file| file.sync_all()) + .map_err(display_error)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o644)).map_err(display_error)?; + } + File::open(&input.staging.payload) + .and_then(|file| file.sync_all()) + .map_err(display_error) +} + +fn package_members(payload: &Path, version: &str) -> Result> { + let entries = fs::read_dir(payload) + .map_err(display_error)? + .map(|entry| entry.map(|entry| entry.path())); + package_members_from_paths(entries, version) +} + +pub(crate) fn package_members_from_paths( + entries: impl IntoIterator>, + version: &str, +) -> Result> { + let mut members = Vec::new(); + for path in entries { + let path = path.map_err(display_error)?; + if path + .file_name() + .and_then(OsStr::to_str) + .and_then(|name| artifact_kind(name, Some(version)).ok()) + .is_some() + { + members.push(package_member_evidence(&path, version)?); + } + } + Ok(members) +} + +fn recheck_source(root: &RepoRoot, context: &ImmutableContext, payload: &Path) -> Result<()> { + require_clean_tree(root.path(), payload)?; + let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; + if commit != context.commit { + return Err(Error::new(format!( + "release commit mismatch: expected {}, actual {commit}\nrepair: checkout the expected commit and retry", + context.commit + ))); + } + let lock = digest(&fs::read(root.path().join("Cargo.lock")).map_err(display_error)?); + if lock != context.cargo_lock_sha256 { + return Err(Error::new(format!( + "Cargo.lock digest mismatch: expected {}, actual {lock}\nrepair: restore the committed Cargo.lock and retry", + context.cargo_lock_sha256 + ))); + } + Ok(()) +} + +pub fn rollback_error( + original: Error, + payload: &Path, + evidence_root: &Path, + owned_proofs: &[PathBuf], +) -> Error { + let mut residues = Vec::new(); + if payload.symlink_metadata().is_ok() && fs::remove_dir_all(payload).is_err() { + residues.push(payload.to_owned()); + } + for path in owned_proofs + .iter() + .rev() + .chain(std::iter::once(&evidence_root.join("ledger.json"))) + { + if path.symlink_metadata().is_ok() && fs::remove_file(path).is_err() { + residues.push(path.to_owned()); + } + } + let proofs_root = evidence_root.join("proofs"); + if proofs_root.is_dir() + && fs::read_dir(&proofs_root).is_ok_and(|mut entries| entries.next().is_none()) + && fs::remove_dir(&proofs_root).is_err() + { + residues.push(proofs_root); + } + if evidence_root.is_dir() + && fs::read_dir(evidence_root).is_ok_and(|mut entries| entries.next().is_none()) + && fs::remove_dir(evidence_root).is_err() + { + residues.push(evidence_root.to_owned()); + } + if residues.is_empty() { + original + } else { + let paths = residues + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + Error::new(format!( + "{original}\nerror: release candidate rollback mismatch: expected owned payload and evidence absent, actual residue at {paths}\nrepair: remove {paths} after confirming no release-candidate process holds dist/.rust-release-candidate.lock" + )) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateProof { + pub schema_version: u64, + pub platform: String, + pub candidate_digest: String, + pub ledger_sha256: String, + pub source_commit: String, + pub cargo_lock_sha256: String, + pub artifact_basename: String, + pub artifact_bytes: u64, + pub artifact_sha256: String, + pub proof_image_digest: String, + pub os_release: String, + pub package_manager_version: String, + pub install_command: Vec, + pub install_exit_status: i64, + pub version_command: Vec, + pub version_exit_status: i64, + pub executable_path: String, + pub executable_mode: u64, + pub executable_sha256: String, + pub version_output: String, + pub result: String, + pub proof_time: String, + pub architecture: String, + pub network: String, + pub isolation: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub dry_run_passed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub isolated_prefix_passed: Option, +} + +pub struct ProofRequest<'a> { + pub root: &'a RepoRoot, + pub ledger: &'a CandidateLedger, + pub ledger_bytes: &'a [u8], + pub platform: &'a str, + pub image: &'a ImageIdentity, + pub engine: ContainerEngine, + pub processes: &'a ProcessEnvironment, +} + +pub fn produce_or_retain_proof(request: &ProofRequest<'_>) -> Result { + if !PROOF_IDS.contains(&request.platform) { + return Err(Error::new( + "proof platform mismatch: expected known proof ID, actual unknown", + )); + } + let evidence_root = request + .root + .path() + .join("dist/rust-evidence") + .join(&request.ledger.version); + let proofs_root = evidence_root.join("proofs"); + fs::create_dir_all(&proofs_root).map_err(display_error)?; + let final_path = proofs_root.join(format!("{}.json", request.platform)); + if final_path.symlink_metadata().is_ok() { + validate_proof_file(request, &final_path)?; + return Ok(final_path); + } + let attempt = proofs_root.join(format!( + ".{}.{}.attempt", + request.platform, + transaction_id()? + )); + fs::create_dir(&attempt).map_err(display_error)?; + let artifact = proof_artifact(request.ledger, request.platform)?; + let artifact_path = request.root.path().join("dist/rust").join(&artifact.path); + require_regular(&artifact_path, "proof artifact")?; + let executable = std::env::current_exe().map_err(display_error)?; + let output_arg = format!("type=bind,src={},dst=/evidence", attempt.display()); + let artifact_arg = format!( + "type=bind,src={},dst=/input/{},ro", + artifact_path.display(), + artifact.path + ); + let runner_arg = format!( + "type=bind,src={},dst=/proof-runner,ro", + executable.display() + ); + let mut args = vec![ + "run".into(), + "--rm".into(), + "--pull=never".into(), + "--network=none".into(), + "--mount".into(), + output_arg, + "--mount".into(), + artifact_arg, + "--mount".into(), + runner_arg, + ]; + if request.platform == "tar-x86_64" { + args.extend([ + "--mount".into(), + format!( + "type=bind,src={},dst=/input/install.sh,ro", + request.root.path().join("scripts/install.sh").display() + ), + ]); + } + args.extend([ + request.image.digest.clone(), + "/proof-runner".into(), + "proof-handoff".into(), + "--platform".into(), + request.platform.into(), + "--artifact".into(), + format!("/input/{}", artifact.path), + "--output".into(), + "/evidence/proof.json".into(), + "--candidate-digest".into(), + request.ledger.candidate_digest.clone(), + "--ledger-sha256".into(), + digest(request.ledger_bytes), + "--source-commit".into(), + request.ledger.source.commit.clone(), + "--cargo-lock-sha256".into(), + request.ledger.source.cargo_lock_sha256.clone(), + "--proof-image-digest".into(), + request.image.digest.clone(), + "--version".into(), + request.ledger.version.clone(), + ]); + let run = run_success_owned( + request.processes, + request.root.path(), + request.engine.executable(), + &args, + ); + if let Err(error) = run { + let _ = fs::remove_dir_all(&attempt); + return Err(error); + } + let produced = attempt.join("proof.json"); + let validation = validate_proof_file(request, &produced); + if let Err(error) = validation { + let _ = fs::remove_dir_all(&attempt); + return Err(error); + } + let bytes = fs::read(&produced).map_err(display_error)?; + fs::remove_dir_all(&attempt).map_err(display_error)?; + atomic_write_0644(&final_path, &bytes)?; + validate_proof_file(request, &final_path)?; + Ok(final_path) +} + +pub(crate) fn proof_artifact<'a>( + ledger: &'a CandidateLedger, + platform: &str, +) -> Result<&'a Artifact> { + let kind = match platform { + "debian-amd64" => "deb", + "rpm-x86_64" => "rpm", + "tar-x86_64" => "tar", + _ => return Err(Error::new("proof platform mismatch")), + }; + ledger + .payload + .iter() + .find(|item| artifact_kind(&item.path, None).ok() == Some(kind)) + .ok_or_else(|| { + Error::new("proof artifact mismatch: expected ledger artifact, actual missing") + }) +} + +pub(crate) fn proof_member<'a>( + ledger: &'a CandidateLedger, + platform: &str, +) -> Result<&'a PackageMemberEvidence> { + let artifact = proof_artifact(ledger, platform)?; + ledger + .package_members + .iter() + .find(|item| item.package_file == artifact.path) + .ok_or_else(|| { + Error::new("proof package member mismatch: expected ledger member, actual missing") + }) +} + +pub fn validate_proof_file(request: &ProofRequest<'_>, path: &Path) -> Result { + require_regular(path, "candidate proof")?; + let bytes = fs::read(path).map_err(display_error)?; + let proof: CandidateProof = serde_json::from_slice(&bytes).map_err(display_error)?; + if canonical_json(&serde_json::to_value(&proof).map_err(display_error)?)? != bytes { + return Err(Error::new("proof canonicalization mismatch")); + } + let artifact = proof_artifact(request.ledger, request.platform)?; + let member = proof_member(request.ledger, request.platform)?; + let release_policy = ReleaseImages::from_root(request.root.path())?; + let policy = release_policy.proof_policy(request.platform)?; + if policy.image_digest != request.image.digest + || policy.executable_path != member.installed_path + || policy.executable_mode != member.mode + { + return Err(Error::new( + "proof platform policy mismatch: expected ledger-bound image and executable, actual different", + )); + } + let expected = ProofBindings { + platform: request.platform.into(), + candidate_digest: request.ledger.candidate_digest.clone(), + ledger_sha256: digest(request.ledger_bytes), + source_commit: request.ledger.source.commit.clone(), + cargo_lock_sha256: request.ledger.source.cargo_lock_sha256.clone(), + artifact_basename: artifact.path.clone(), + artifact_bytes: artifact.bytes, + artifact_sha256: artifact.sha256.clone(), + proof_image_digest: request.image.digest.clone(), + os_release: policy.os_release.clone(), + package_manager_version: policy.package_manager_version.clone(), + install_command: policy.install_command.clone(), + install_exit_status: 0, + version_command: policy.version_command.clone(), + version_exit_status: 0, + executable_path: policy.executable_path.clone(), + executable_mode: policy.executable_mode, + executable_sha256: member.sha256.clone(), + version_output: format!("solstone-linux {}", request.ledger.version), + result: "pass".into(), + policy_checked_at: request.ledger.policy.checked_at.clone(), + validation_time: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }; + validate_candidate_proof( + &serde_json::to_value(&proof).map_err(display_error)?, + &expected, + )?; + if proof.network != "none" + || proof.isolation != "fresh-container" + || proof.architecture + != if request.platform == "debian-amd64" { + "amd64" + } else { + "x86_64" + } + { + return Err(Error::new("proof environment mismatch")); + } + if request.platform == "tar-x86_64" + && (proof.dry_run_passed != Some(true) || proof.isolated_prefix_passed != Some(true)) + { + return Err(Error::new("tar proof installer mismatch")); + } + if request.platform != "tar-x86_64" + && (proof.dry_run_passed.is_some() || proof.isolated_prefix_passed.is_some()) + { + return Err(Error::new("native proof fields mismatch")); + } + validate_identity( + if request.platform == "rpm-x86_64" { + "fedora_os" + } else { + "ubuntu_os" + }, + &proof.os_release, + )?; + let manager_ok = match request.platform { + "debian-amd64" => { + proof + .package_manager_version + .starts_with("Debian 'dpkg' package management program version ") + || proof.package_manager_version.starts_with("dpkg ") + } + "rpm-x86_64" => { + proof.package_manager_version.starts_with("RPM version ") + || proof.package_manager_version.starts_with("rpm ") + } + "tar-x86_64" => proof.package_manager_version == "installer portable-tar", + _ => false, + }; + if !manager_ok { + return Err(Error::new( + "proof package manager mismatch: expected approved identity, actual different", + )); + } + Ok(proof) +} + +#[derive(Clone, Debug, Serialize)] +pub struct CandidateStatus { + pub status: String, + pub local_evidence_only: bool, + pub publication_approval: bool, + pub candidate_digest: String, + pub bundle_digest: String, + pub ledger_sha256: String, + pub proofs: BTreeMap, + pub payload: Vec, +} + +pub fn candidate_status( + root: &RepoRoot, + expected_ledger: &CandidateLedger, + expected_ledger_bytes: &[u8], +) -> Result { + validate_version(&expected_ledger.version)?; + let (disk_ledger, disk_ledger_bytes) = read_ledger(root, &expected_ledger.version)?; + if disk_ledger_bytes != expected_ledger_bytes + || disk_ledger_bytes != ledger_bytes(root, &root.path().join("dist/rust"), expected_ledger)? + { + return Err(Error::new( + "candidate ledger bytes mismatch: expected promoted ledger, actual different", + )); + } + let ledger = &disk_ledger; + let promoted_ledger_bytes = disk_ledger_bytes.as_slice(); + let policy = ReleaseImages::from_root(root.path())?; + if proof_image_identity(&policy.build_ubuntu).digest != ledger.images.ubuntu_image_id + || proof_image_identity(&policy.build_fedora).digest != ledger.images.fedora_image_id + { + return Err(Error::new( + "candidate image policy mismatch: expected ledger build images, actual committed policy differs", + )); + } + let proof_references = [ + ("debian-amd64", policy.proof_debian), + ("rpm-x86_64", policy.proof_rpm), + ("tar-x86_64", policy.proof_tar), + ]; + let evidence_root = root.path().join("dist/rust-evidence").join(&ledger.version); + let proofs_root = evidence_root.join("proofs"); + let evidence_names = directory_names(&evidence_root, "candidate evidence")?; + if evidence_names != BTreeSet::from(["ledger.json".into(), "proofs".into()]) { + return Err(Error::new( + "candidate evidence inventory mismatch: expected ledger and proofs, actual different", + )); + } + let expected_proof_names = PROOF_IDS + .iter() + .map(|id| format!("{id}.json")) + .collect::>(); + if directory_names(&proofs_root, "candidate proofs")? != expected_proof_names { + return Err(Error::new( + "candidate proof inventory mismatch: expected exactly three proofs, actual different", + )); + } + let mut proof_bytes = BTreeMap::new(); + for (id, reference) in proof_references { + let path = proofs_root.join(format!("{id}.json")); + require_regular(&path, id)?; + let image = proof_image_identity(&reference); + validate_proof_file( + &ProofRequest { + root, + ledger, + ledger_bytes: promoted_ledger_bytes, + platform: id, + image: &image, + engine: ContainerEngine::Podman, + processes: &ProcessEnvironment::default(), + }, + &path, + )?; + proof_bytes.insert(id.into(), fs::read(path).map_err(display_error)?); + } + // The exact three proof IDs and every proof binding were validated above; + // bundle construction therefore receives a complete, canonical inventory. + let bundle = bundle_digest( + &ledger.candidate_digest, + promoted_ledger_bytes, + &proof_bytes, + )?; + Ok(CandidateStatus { + status: "candidate-proven".into(), + local_evidence_only: true, + publication_approval: false, + candidate_digest: ledger.candidate_digest.clone(), + bundle_digest: bundle, + ledger_sha256: digest(promoted_ledger_bytes), + proofs: proof_bytes + .iter() + .map(|(id, bytes)| (id.clone(), digest(bytes))) + .collect(), + payload: ledger.payload.clone(), + }) +} + +fn directory_names(path: &Path, label: &str) -> Result> { + require_directory(path, label)?; + fs::read_dir(path) + .map_err(display_error)? + .map(|entry| { + entry + .map_err(display_error)? + .file_name() + .into_string() + .map_err(|_| Error::new(format!("{label} contains non-UTF-8 path"))) + }) + .collect() +} + +pub(crate) fn proof_image_identity(reference: &str) -> ImageIdentity { + let digest = format!("sha256:{}", reference.rsplit_once("sha256:").unwrap().1); + ImageIdentity { + configured_tag: reference.into(), + digest, + } +} + +pub fn recover_candidate(root: &RepoRoot, version: &str) -> Result { + if root + .path() + .join("dist/.rust-release-candidate.lock") + .symlink_metadata() + .is_ok() + { + return Err(Error::new( + "candidate recovery lock mismatch: expected absent, actual present", + )); + } + let before = tree_snapshot(&root.path().join("dist"))?; + let (ledger, bytes) = read_ledger(root, version)?; + require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; + if commit != ledger.source.commit { + return Err(Error::new(format!( + "recovery commit mismatch: expected {}, actual {commit}", + ledger.source.commit + ))); + } + if digest(&fs::read(root.path().join("Cargo.lock")).map_err(display_error)?) + != ledger.source.cargo_lock_sha256 + { + return Err(Error::new("recovery Cargo.lock mismatch")); + } + classify_release(root, &root.path().join("dist/rust"), false)?; + let _ = candidate_status(root, &ledger, &bytes)?; + if tree_snapshot(&root.path().join("dist"))? != before { + return Err(Error::new("candidate recovery mutation mismatch")); + } + Ok("retained-candidate-valid".into()) +} + +fn tree_snapshot(root: &Path) -> Result> { + fn walk(base: &Path, path: &Path, out: &mut BTreeMap) -> Result<()> { + if !path.exists() { + return Ok(()); + } + for entry in fs::read_dir(path).map_err(display_error)? { + let entry = entry.map_err(display_error)?; + let child = entry.path(); + let relative = child.strip_prefix(base).map_err(display_error)?.to_owned(); + let metadata = fs::symlink_metadata(&child).map_err(display_error)?; + if metadata.file_type().is_symlink() { + out.insert(relative, "symlink".into()); + } else if metadata.is_dir() { + out.insert(relative, "dir".into()); + walk(base, &child, out)?; + } else { + out.insert(relative, digest(&fs::read(child).map_err(display_error)?)); + } + } + Ok(()) + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out)?; + Ok(out) +} + +pub struct ProofHandoffInput<'a> { + pub platform: &'a str, + pub artifact: &'a Path, + pub output: &'a Path, + pub candidate_digest: &'a str, + pub ledger_sha256: &'a str, + pub source_commit: &'a str, + pub cargo_lock_sha256: &'a str, + pub proof_image_digest: &'a str, + pub version: &'a str, +} + +pub fn emit_proof_handoff(input: &ProofHandoffInput<'_>) -> Result<()> { + if Command::new("sh") + .args(["-c", "command -v solstone-linux >/dev/null 2>&1"]) + .status() + .map_err(display_error)? + .success() + { + return Err(Error::new( + "proof pre-existing install mismatch: expected absent, actual present", + )); + } + let root = Path::new("/proof-root"); + if root.symlink_metadata().is_ok() { + return Err(Error::new( + "proof isolation root mismatch: expected absent, actual present", + )); + } + fs::create_dir(root).map_err(display_error)?; + let artifact_name = input + .artifact + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| Error::new("proof artifact basename mismatch"))? + .to_owned(); + let artifact_record = artifact(input.artifact)?; + let ( + install_command, + executable_path, + actual_executable, + package_manager_version, + architecture, + dry, + isolated, + ) = match input.platform { + "debian-amd64" => { + let command = vec![ + "dpkg".into(), + "--root=/proof-root".into(), + "--install".into(), + input.artifact.display().to_string(), + ]; + run_exact(&command)?; + ( + command, + "/usr/bin/solstone-linux", + root.join("usr/bin/solstone-linux"), + command_line("dpkg", &["--version"])?, + "amd64", + None, + None, + ) + } + "rpm-x86_64" => { + run_exact(&[ + "rpm".into(), + "--root".into(), + "/proof-root".into(), + "--initdb".into(), + ])?; + let command = vec![ + "rpm".into(), + "--root".into(), + "/proof-root".into(), + "--install".into(), + input.artifact.display().to_string(), + ]; + run_exact(&command)?; + ( + command, + "/usr/bin/solstone-linux", + root.join("usr/bin/solstone-linux"), + command_line("rpm", &["--version"])?, + "x86_64", + None, + None, + ) + } + "tar-x86_64" => { + fs::remove_dir(root).map_err(display_error)?; + let script = Path::new("/input/install.sh"); + let dry_command = vec![ + script.display().to_string(), + "--dry-run".into(), + "--prefix".into(), + "/proof-root".into(), + input.artifact.display().to_string(), + ]; + run_exact(&dry_command)?; + if root.symlink_metadata().is_ok() { + return Err(Error::new("tar proof dry-run mutation mismatch")); + } + let command = vec![ + script.display().to_string(), + "--prefix".into(), + "/proof-root".into(), + input.artifact.display().to_string(), + ]; + run_exact(&command)?; + ( + command, + "/bin/solstone-linux", + root.join("bin/solstone-linux"), + "installer portable-tar".into(), + "x86_64", + Some(true), + Some(true), + ) + } + _ => return Err(Error::new("proof platform mismatch")), + }; + require_regular(&actual_executable, "proof installed executable")?; + let metadata = fs::symlink_metadata(&actual_executable).map_err(display_error)?; + let version_command = vec![executable_path.into(), "--version".into()]; + let output = Command::new(&actual_executable) + .arg("--version") + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new( + "proof version command mismatch: expected success", + )); + } + let version_output = String::from_utf8(output.stdout) + .map_err(display_error)? + .trim() + .to_owned(); + let proof = CandidateProof { + schema_version: 1, + platform: input.platform.into(), + candidate_digest: input.candidate_digest.into(), + ledger_sha256: input.ledger_sha256.into(), + source_commit: input.source_commit.into(), + cargo_lock_sha256: input.cargo_lock_sha256.into(), + artifact_basename: artifact_name, + artifact_bytes: artifact_record.bytes, + artifact_sha256: artifact_record.sha256, + proof_image_digest: input.proof_image_digest.into(), + os_release: proof_os_release()?, + package_manager_version: package_manager_version + .lines() + .next() + .unwrap_or_default() + .into(), + install_command, + install_exit_status: 0, + version_command, + version_exit_status: 0, + executable_path: executable_path.into(), + executable_mode: metadata.permissions().mode() as u64 & 0o7777, + executable_sha256: digest(&fs::read(actual_executable).map_err(display_error)?), + version_output, + result: "pass".into(), + proof_time: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + architecture: architecture.into(), + network: "none".into(), + isolation: "fresh-container".into(), + dry_run_passed: dry, + isolated_prefix_passed: isolated, + }; + let bytes = canonical_json(&serde_json::to_value(proof).map_err(display_error)?)?; + fs::write(input.output, bytes).map_err(display_error) +} + +fn run_exact(args: &[String]) -> Result<()> { + let output = Command::new(&args[0]) + .args(&args[1..]) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new(format!( + "proof command mismatch: expected success, actual {}", + output.status + ))); + } + Ok(()) +} +fn command_line(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(display_error)?; + if !output.status.success() { + return Err(Error::new("proof identity command mismatch")); + } + String::from_utf8(output.stdout) + .map(|s| s.trim().to_owned()) + .map_err(display_error) +} +fn proof_os_release() -> Result { + let text = fs::read_to_string("/etc/os-release").map_err(display_error)?; + text.lines() + .find_map(|line| line.strip_prefix("PRETTY_NAME=")) + .map(|s| s.trim_matches(['\'', '"']).to_owned()) + .ok_or_else(|| Error::new("proof OS mismatch")) +} + +pub(crate) fn finish_candidate_staging( + root: &RepoRoot, + version: &str, + staging_root: &Path, + result: Result, +) -> Result { + match fs::remove_dir_all(staging_root) { + Ok(()) => result, + Err(cleanup) => { + let primary = result.err().unwrap_or_else(|| { + Error::new("candidate staging cleanup mismatch: expected owned root absent") + }); + let evidence_root = root.path().join("dist/rust-evidence").join(version); + let mut rolled_back = + rollback_error(primary, &root.path().join("dist/rust"), &evidence_root, &[]); + if evidence_root.symlink_metadata().is_ok() + && let Err(evidence_cleanup) = fs::remove_dir_all(&evidence_root) + { + rolled_back = Error::new(format!( + "{rolled_back}\nerror: candidate evidence cleanup mismatch: expected owned evidence absent, actual residue\nrepair: remove {}: {evidence_cleanup}", + evidence_root.display() + )); + } + Err(Error::new(format!( + "{rolled_back}\nerror: candidate staging cleanup mismatch: expected owned root absent, actual residue\nrepair: remove {} after confirming no release-candidate process holds dist/.rust-release-candidate.lock: {cleanup}", + staging_root.display() + ))) + } + } +} + +pub(crate) fn finish_created_candidate( + finalized: &FinalizedCandidate, + owned_proofs: &[PathBuf], + result: Result, +) -> Result { + result.map_err(|error| { + rollback_error( + error, + &finalized.payload_root, + &finalized.evidence_root, + owned_proofs, + ) + }) +} + +pub fn create_candidate( + root: &RepoRoot, + expected_commit: &str, + descriptor: &Path, + processes: &ProcessEnvironment, +) -> Result { + require_expected_commit(root, expected_commit)?; + let lock = CandidateLock::acquire(root)?; + let version = workspace_version(root)?; + require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + clear_candidate_paths(root, &version)?; + let staging = StagingLayout::create(root, &lock)?; + let result = (|| { + let context = export_immutable_context(root, &staging.context)?; + if context.commit != expected_commit { + return Err(Error::new(format!( + "release commit mismatch: expected {expected_commit}, actual {}\nrepair: checkout the expected commit and retry", + context.commit + ))); + } + let image_policy = ReleaseImages::from_context(&context)?; + let engine = detect_container_engine(processes)?; + let engine_identity = observe_container_engine(processes, engine)?; + let images = resolve_release_images(processes, engine, &image_policy)?; + let cohort = run_advisory_cohort(&context, &staging, descriptor, processes)?; + let invocation = staging + .root + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| Error::new("candidate invocation mismatch"))?; + let deb = build_lane(&LaneRequest { + repo: root, + context: &context, + lane: Lane::Deb, + engine, + invocation_id: invocation, + version: &version, + ubuntu: &images.build_ubuntu, + fedora: &images.build_fedora, + output: &staging.deb_lane, + processes, + })?; + let rpm = build_lane(&LaneRequest { + repo: root, + context: &context, + lane: Lane::Rpm, + engine, + invocation_id: invocation, + version: &version, + ubuntu: &images.build_ubuntu, + fedora: &images.build_fedora, + output: &staging.rpm_lane, + processes, + })?; + let finalized = finalize_candidate(FinalizeInput { + root, + staging: &staging, + context: &context, + version: &version, + deb: &deb, + rpm: &rpm, + cohort: &cohort, + images: &images, + engine, + engine_identity, + processes, + })?; + let mut owned = Vec::new(); + let proof_result = (|| { + for (id, image) in proof_images(&images) { + owned.push( + finalized + .evidence_root + .join("proofs") + .join(format!("{id}.json")), + ); + produce_or_retain_proof(&ProofRequest { + root, + ledger: &finalized.ledger, + ledger_bytes: &finalized.ledger_bytes, + platform: id, + image, + engine, + processes, + })?; + } + recheck_all_images(processes, engine, &images)?; + recheck_source(root, &context, &finalized.payload_root)?; + recheck_advisory_cohort(&cohort, processes, Utc::now())?; + candidate_status(root, &finalized.ledger, &finalized.ledger_bytes) + })(); + finish_created_candidate(&finalized, &owned, proof_result) + })(); + finish_candidate_staging(root, &version, &staging.root, result) +} + +pub fn prove_candidate( + root: &RepoRoot, + version: &str, + descriptor: &Path, + processes: &ProcessEnvironment, +) -> Result { + validate_version(version)?; + let _lock = CandidateLock::acquire(root)?; + let (ledger, ledger_bytes) = read_ledger(root, version)?; + require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; + let lock_digest = digest(&fs::read(root.path().join("Cargo.lock")).map_err(display_error)?); + let archive_digest = digest(&command_bytes( + root.path(), + &["git", "archive", "--format=tar", "HEAD"], + )?); + if commit != ledger.source.commit + || lock_digest != ledger.source.cargo_lock_sha256 + || archive_digest != ledger.source.archive_sha256 + { + return Err(Error::new( + "candidate resume source mismatch: expected ledger source, actual checkout differs", + )); + } + classify_release(root, &root.path().join("dist/rust"), false)?; + let advisory = validate_resume_advisory_identity(descriptor, processes)?; + if advisory.source_id != ledger.advisory_cohort.source_id + || advisory.commit != ledger.advisory_cohort.commit + || advisory.archive_sha256 != ledger.advisory_cohort.archive_sha256 + { + return Err(Error::new( + "candidate resume advisory cohort mismatch: expected retained identity, actual descriptor differs\nrepair: provide the exact retained advisory database", + )); + } + let policy = ReleaseImages::from_root(root.path())?; + let engine = detect_container_engine(processes)?; + let images = resolve_release_images(processes, engine, &policy)?; + if images.build_ubuntu.digest != ledger.images.ubuntu_image_id + || images.build_fedora.digest != ledger.images.fedora_image_id + { + return Err(Error::new("candidate resume build image mismatch")); + } + preflight_existing_proofs(root, &ledger, &ledger_bytes, &images, engine, processes)?; + + let context = ImmutableContext { + commit, + archive_sha256: archive_digest, + cargo_lock_sha256: lock_digest, + path: root.path().to_owned(), + }; + for (id, image) in proof_images(&images) { + produce_or_retain_proof(&ProofRequest { + root, + ledger: &ledger, + ledger_bytes: &ledger_bytes, + platform: id, + image, + engine, + processes, + })?; + } + recheck_all_images(processes, engine, &images)?; + recheck_source(root, &context, &root.path().join("dist/rust"))?; + let final_advisory = validate_resume_advisory_identity(descriptor, processes)?; + if final_advisory.source_id != advisory.source_id + || final_advisory.commit != advisory.commit + || final_advisory.archive_sha256 != advisory.archive_sha256 + { + return Err(Error::new( + "candidate resume advisory cohort mismatch: expected unchanged identity, actual changed", + )); + } + candidate_status(root, &ledger, &ledger_bytes) +} + +fn preflight_existing_proofs( + root: &RepoRoot, + ledger: &CandidateLedger, + ledger_bytes: &[u8], + images: &ResolvedImages, + engine: ContainerEngine, + processes: &ProcessEnvironment, +) -> Result<()> { + let proofs_root = root + .path() + .join("dist/rust-evidence") + .join(&ledger.version) + .join("proofs"); + if proofs_root.symlink_metadata().is_err() { + return Ok(()); + } + require_directory(&proofs_root, "candidate proofs")?; + let allowed = BTreeSet::from(PROOF_IDS.map(|id| format!("{id}.json"))); + for entry in fs::read_dir(&proofs_root).map_err(display_error)? { + let name = entry + .map_err(display_error)? + .file_name() + .into_string() + .map_err(|_| Error::new("candidate proof basename mismatch"))?; + if !allowed.contains(&name) { + return Err(Error::new( + "candidate proof inventory mismatch: expected only retained proof IDs, actual extra entry", + )); + } + } + for (id, image) in proof_images(images) { + let path = proofs_root.join(format!("{id}.json")); + if path.symlink_metadata().is_ok() { + validate_proof_file( + &ProofRequest { + root, + ledger, + ledger_bytes, + platform: id, + image, + engine, + processes, + }, + &path, + )?; + } + } + Ok(()) +} + +fn proof_images(images: &ResolvedImages) -> [(&'static str, &ImageIdentity); 3] { + [ + ("debian-amd64", &images.proof_debian), + ("rpm-x86_64", &images.proof_rpm), + ("tar-x86_64", &images.proof_tar), + ] +} +fn recheck_all_images( + processes: &ProcessEnvironment, + engine: ContainerEngine, + images: &ResolvedImages, +) -> Result<()> { + for image in [ + &images.build_ubuntu, + &images.build_fedora, + &images.proof_debian, + &images.proof_rpm, + &images.proof_tar, + ] { + let actual = inspect_image(processes, engine, &image.configured_tag)?; + if actual.digest != image.digest { + return Err(Error::new(format!( + "image identity mismatch: expected {}, actual {}", + image.digest, actual.digest + ))); + } + } + Ok(()) +} +fn workspace_version(root: &RepoRoot) -> Result { + let value: toml::Value = + toml::from_str(&fs::read_to_string(root.path().join("Cargo.toml")).map_err(display_error)?) + .map_err(display_error)?; + let version = value["workspace"]["package"]["version"] + .as_str() + .ok_or_else(|| Error::new("workspace version mismatch"))? + .to_owned(); + validate_version(&version)?; + Ok(version) +} +pub(crate) fn require_expected_commit(root: &RepoRoot, expected: &str) -> Result<()> { + require_commit(expected, "expected release commit")?; + let actual = command(root.path(), &["git", "rev-parse", "HEAD"])?; + if actual != expected { + return Err(Error::new(format!( + "release commit mismatch: expected {expected}, actual {actual}\nrepair: checkout the expected commit and retry" + ))); + } + Ok(()) +} +fn clear_candidate_paths(root: &RepoRoot, version: &str) -> Result<()> { + for path in [ + root.path().join("dist/rust"), + root.path().join("dist/rust-evidence").join(version), + ] { + if path.symlink_metadata().is_ok() { + fs::remove_dir_all(path).map_err(display_error)?; + } + } + Ok(()) +} diff --git a/crates/solstone-linux/src/toolchain_policy_tests.rs b/crates/solstone-linux/src/toolchain_policy_tests.rs index 7cd00b5..cecb525 100644 --- a/crates/solstone-linux/src/toolchain_policy_tests.rs +++ b/crates/solstone-linux/src/toolchain_policy_tests.rs @@ -20,6 +20,7 @@ struct ScanCounts { resolving: usize, nested_container: usize, make_wrapper: usize, + direct_generate_rpm: usize, } fn toolchain() -> toml::Value { @@ -355,6 +356,17 @@ fn scan_policy( if matches!(command.trim_matches(['@', '{', '}']), "echo" | "printf") { continue; } + if PathBuf::from(command.trim_matches(['@', '{', '}'])) + .file_name() + .is_some_and(|name| name == "cargo-generate-rpm") + { + if !expanded.contains("CARGO_NET_OFFLINE=true") { + return Err(format!( + "{source}:{line_number}: direct cargo-generate-rpm invocation lacks CARGO_NET_OFFLINE=true" + )); + } + counts.direct_generate_rpm += 1; + } for cargo in cargo_commands(&fragment) { counts.inspected += 1; if was_wrapper { @@ -369,11 +381,9 @@ fn scan_policy( if resolving { counts.resolving += 1; } - let exempt = matches!( - cargo.subcommand, - "fmt" | "generate-rpm" | "clean" | "update" - ) || (cargo.subcommand == "deny" - && (cargo.deny_fetch || cargo.version_query)) + let exempt = matches!(cargo.subcommand, "fmt" | "clean" | "update") + || (cargo.subcommand == "deny" + && (cargo.deny_fetch || cargo.version_query)) || cargo.version_query || cargo.subcommand == "--version"; if resolving && !cargo.locked { @@ -394,6 +404,7 @@ fn scan_policy( || counts.resolving == 0 || counts.nested_container == 0 || counts.make_wrapper == 0 + || counts.direct_generate_rpm == 0 { return Err("policy scan did not inspect required command classes".into()); } @@ -522,11 +533,70 @@ fn locked_policy_fails_closed_on_unresolved_command_wrapper() { #[test] fn locked_policy_requires_a_resolving_invocation() { let makefile = "CARGO := cargo\nprobe:\n\t$(CARGO) fmt\n"; - let container = "RUN cargo fmt && cargo generate-rpm -p crates/solstone-linux\n"; + let container = + "RUN cargo fmt && CARGO_NET_OFFLINE=true cargo-generate-rpm -p crates/solstone-linux\n"; let error = scan_policy(makefile, container, &[]).unwrap_err(); assert!(error.contains("required command classes")); } +#[test] +fn cargo_generate_rpm_is_direct_offline_and_never_a_cargo_subcommand() { + let (makefile, container, scripts) = policy_sources(); + let counts = scan_policy(&makefile, &container, &scripts).unwrap(); + assert_eq!(counts.direct_generate_rpm, 2); + let error = scan_policy( + &makefile, + &(container + "\nRUN cargo generate-rpm -p crates/solstone-linux\n"), + &scripts, + ) + .unwrap_err(); + assert!(error.contains("unclassified Cargo invocation")); +} + +#[test] +fn direct_generate_rpm_path_stub_records_exact_offline_argv() { + let temp = tempfile::tempdir().unwrap(); + let argv = temp.path().join("argv"); + let tripwire = temp.path().join("cargo-tripwire"); + let direct = temp.path().join("cargo-generate-rpm"); + fs::write( + &direct, + format!( + "#!/bin/sh\n[ \"$CARGO_NET_OFFLINE\" = true ] || exit 90\nprintf '%s\\0' \"$@\" >> '{}'\n", + argv.display() + ), + ) + .unwrap(); + fs::set_permissions(&direct, fs::Permissions::from_mode(0o755)).unwrap(); + let cargo = temp.path().join("cargo"); + fs::write( + &cargo, + format!( + "#!/bin/sh\nprintf called > '{}'\nexit 99\n", + tripwire.display() + ), + ) + .unwrap(); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:/usr/bin:/bin", temp.path().display()); + for args in [&["--version"][..], &["-p", "crates/solstone-linux"][..]] { + assert!( + Command::new("cargo-generate-rpm") + .args(args) + .env("PATH", &path) + .env("CARGO_NET_OFFLINE", "true") + .status() + .unwrap() + .success() + ); + } + assert_eq!( + fs::read(argv).unwrap(), + b"--version\0-p\0crates/solstone-linux\0" + ); + assert!(!tripwire.exists()); +} + // AC: shell variables, Make wrappers, paths, and compound commands cannot evade inspection. #[test] fn locked_policy_recognizes_general_cargo_command_forms() { diff --git a/packaging/Containerfile b/packaging/Containerfile index 68eb08a..79bcf65 100644 --- a/packaging/Containerfile +++ b/packaging/Containerfile @@ -1,42 +1,38 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -FROM ubuntu:22.04 AS baseline +ARG UBUNTU_TOOL_BASE +ARG FEDORA_TOOL_BASE +FROM ${UBUNTU_TOOL_BASE} AS baseline ARG DEBIAN_FRONTEND=noninteractive ARG RUST_VERSION=1.97.1 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - cmake \ - curl \ - libglib2.0-dev \ - libgstreamer1.0-dev \ - libpulse-dev \ - pkg-config \ - xz-utils \ - && rm -rf /var/lib/apt/lists/* - -# flac-bound builds libFLAC without Ogg and links it statically. CMake and the C -# toolchain are build-only requirements; no system libFLAC is used at runtime. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain "${RUST_VERSION}" +ARG UBUNTU_TOOL_BASE +RUN case "$UBUNTU_TOOL_BASE" in \ + sha256:*) suffix="${UBUNTU_TOOL_BASE#sha256:}" ;; \ + *@sha256:*) suffix="${UBUNTU_TOOL_BASE##*@sha256:}" ;; \ + *) exit 1 ;; \ + esac \ + && test "${#suffix}" -eq 64 \ + && case "$suffix" in *[!0-9a-f]*) exit 1 ;; *) ;; esac ENV PATH="/root/.cargo/bin:${PATH}" +ENV CARGO_NET_OFFLINE=true WORKDIR /src # The full repository is required: build.rs rasterizes contrib/icons and # deliberately panics if those canonical source files cannot be read. COPY . . -RUN actual="$(rustc --version --verbose | sed -n 's/^release: //p')" \ - && test "${actual}" = "${RUST_VERSION}" \ - || { echo "error: Rust compiler mismatch: expected ${RUST_VERSION}, actual ${actual:-unavailable}" >&2; \ +RUN rustc --version --verbose > /tmp/rustc-verbose \ + && grep -Fx "release: ${RUST_VERSION}" /tmp/rustc-verbose \ + && grep -Fx "host: x86_64-unknown-linux-gnu" /tmp/rustc-verbose \ + && grep -Eq "^rustc ${RUST_VERSION} \([0-9a-f]+ [0-9]{4}-[0-9]{2}-[0-9]{2}\)$" /tmp/rustc-verbose \ + || { echo "error: Rust compiler mismatch: expected ${RUST_VERSION} x86_64-unknown-linux-gnu verbose banner, actual different" >&2; \ echo "repair: rustup toolchain install ${RUST_VERSION} --profile minimal" >&2; exit 1; } # Cargo currently serializes package name immediately before version. Keep the # non-empty guard below so any JSON ordering change fails the build. -RUN cargo build --locked --release -p solstone-linux \ +RUN cargo build --locked --release -p solstone-linux -p rust-release-manifest \ && VERSION=$(cargo metadata --locked --format-version 1 --no-deps \ | sed -n 's/.*"name":"solstone-linux","version":"\([^"]*\)".*/\1/p') \ && { test -n "$VERSION" \ @@ -54,53 +50,103 @@ RUN cargo build --locked --release -p solstone-linux \ FROM baseline AS deb-build ARG CARGO_DEB_VERSION=3.7.0 -RUN cargo install cargo-deb --version "${CARGO_DEB_VERSION}" --locked \ - && actual="$(cargo deb --version)" \ +ARG INVOCATION_ID +ARG SOURCE_COMMIT +ARG SOURCE_ARCHIVE_SHA256 +ARG CARGO_LOCK_SHA256 +ARG RELEASE_VERSION +ARG UBUNTU_TOOL_BASE +RUN actual="$(cargo deb --version)" \ && { test "${actual}" = "cargo-deb ${CARGO_DEB_VERSION}" \ || { echo "error: cargo-deb mismatch: expected 'cargo-deb ${CARGO_DEB_VERSION}', actual '${actual:-unavailable}'" >&2; \ - echo "repair: cargo install cargo-deb --version ${CARGO_DEB_VERSION} --locked" >&2; exit 1; }; } \ + echo "repair: provision the local Ubuntu tool image with cargo-deb ${CARGO_DEB_VERSION}" >&2; exit 1; }; } \ && cargo deb --locked -p solstone-linux \ && VERSION=$(cat /release/VERSION) \ + && test "$VERSION" = "$RELEASE_VERSION" \ && DEB="target/debian/solstone-linux_${VERSION}-1_amd64.deb" \ && { test -f "$DEB" \ || { echo "error: Debian artifact mismatch: expected ${DEB}, actual missing" >&2; \ echo "repair: cargo deb --locked -p solstone-linux" >&2; exit 1; }; } \ - && cp "$DEB" "/release/solstone-linux_${VERSION}-1_amd64.deb" \ + && DEB_OUT="/release/solstone-linux_${VERSION}-1_amd64.deb" \ + && TAR_OUT="/release/solstone-linux-${VERSION}-linux-x86_64.tar.gz" \ + && cp "$DEB" "$DEB_OUT" \ + && target/release/rust-release-manifest lane-handoff \ + --lane deb \ + --invocation-id "$INVOCATION_ID" \ + --source-commit "$SOURCE_COMMIT" \ + --source-archive-sha256 "$SOURCE_ARCHIVE_SHA256" \ + --cargo-lock-sha256 "$CARGO_LOCK_SHA256" \ + --version "$VERSION" \ + --target x86_64-unknown-linux-gnu \ + --profile release \ + --image-digest "$UBUNTU_TOOL_BASE" \ + --baseline-executable target/release/solstone-linux \ + --artifact "$TAR_OUT" \ + --artifact "$DEB_OUT" \ + --output /release/.lane-evidence-handoff.json \ && rm /release/VERSION FROM scratch AS deb COPY --from=deb-build /release/ / -FROM fedora:42 AS rpm-build +FROM ${FEDORA_TOOL_BASE} AS rpm-build ARG RUST_VERSION=1.97.1 ARG CARGO_GENERATE_RPM_VERSION=0.21.0 -RUN dnf install -y ca-certificates curl gcc glibc-devel rpm-build \ - && dnf clean all \ - && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain "${RUST_VERSION}" +ARG FEDORA_TOOL_BASE +RUN case "$FEDORA_TOOL_BASE" in \ + sha256:*) suffix="${FEDORA_TOOL_BASE#sha256:}" ;; \ + *@sha256:*) suffix="${FEDORA_TOOL_BASE##*@sha256:}" ;; \ + *) exit 1 ;; \ + esac \ + && test "${#suffix}" -eq 64 \ + && case "$suffix" in *[!0-9a-f]*) exit 1 ;; *) ;; esac ENV PATH="/root/.cargo/bin:${PATH}" +ENV CARGO_NET_OFFLINE=true WORKDIR /src COPY --from=baseline /src /src COPY --from=baseline /release /release -RUN actual="$(rustc --version --verbose | sed -n 's/^release: //p')" \ - && test "${actual}" = "${RUST_VERSION}" \ - || { echo "error: Rust compiler mismatch: expected ${RUST_VERSION}, actual ${actual:-unavailable}" >&2; \ +RUN rustc --version --verbose > /tmp/rustc-verbose \ + && grep -Fx "release: ${RUST_VERSION}" /tmp/rustc-verbose \ + && grep -Fx "host: x86_64-unknown-linux-gnu" /tmp/rustc-verbose \ + && grep -Eq "^rustc ${RUST_VERSION} \([0-9a-f]+ [0-9]{4}-[0-9]{2}-[0-9]{2}\)$" /tmp/rustc-verbose \ + || { echo "error: Rust compiler mismatch: expected ${RUST_VERSION} x86_64-unknown-linux-gnu verbose banner, actual different" >&2; \ echo "repair: rustup toolchain install ${RUST_VERSION} --profile minimal" >&2; exit 1; } -RUN cargo install cargo-generate-rpm --version "${CARGO_GENERATE_RPM_VERSION}" --locked \ - && actual="$(cargo generate-rpm --version)" \ +ARG INVOCATION_ID +ARG SOURCE_COMMIT +ARG SOURCE_ARCHIVE_SHA256 +ARG CARGO_LOCK_SHA256 +ARG RELEASE_VERSION +RUN actual="$(CARGO_NET_OFFLINE=true cargo-generate-rpm --version)" \ && { test "${actual}" = "cargo-generate-rpm ${CARGO_GENERATE_RPM_VERSION}" \ || { echo "error: cargo-generate-rpm mismatch: expected 'cargo-generate-rpm ${CARGO_GENERATE_RPM_VERSION}', actual '${actual:-unavailable}'" >&2; \ - echo "repair: cargo install cargo-generate-rpm --version ${CARGO_GENERATE_RPM_VERSION} --locked" >&2; exit 1; }; } \ - && cargo generate-rpm -p crates/solstone-linux \ + echo "repair: provision the local Fedora tool image with cargo-generate-rpm ${CARGO_GENERATE_RPM_VERSION}" >&2; exit 1; }; } \ + && CARGO_NET_OFFLINE=true cargo-generate-rpm -p crates/solstone-linux \ && VERSION=$(cat /release/VERSION) \ + && test "$VERSION" = "$RELEASE_VERSION" \ && RPM="target/generate-rpm/solstone-linux-${VERSION}-1.x86_64.rpm" \ && { test -f "$RPM" \ || { echo "error: RPM artifact mismatch: expected ${RPM}, actual missing" >&2; \ - echo "repair: cargo generate-rpm -p crates/solstone-linux" >&2; exit 1; }; } \ - && cp "$RPM" "/release/solstone-linux-${VERSION}-1.x86_64.rpm" \ + echo "repair: CARGO_NET_OFFLINE=true cargo-generate-rpm -p crates/solstone-linux" >&2; exit 1; }; } \ + && RPM_OUT="/release/solstone-linux-${VERSION}-1.x86_64.rpm" \ + && TAR_OUT="/release/solstone-linux-${VERSION}-linux-x86_64.tar.gz" \ + && cp "$RPM" "$RPM_OUT" \ + && target/release/rust-release-manifest lane-handoff \ + --lane rpm \ + --invocation-id "$INVOCATION_ID" \ + --source-commit "$SOURCE_COMMIT" \ + --source-archive-sha256 "$SOURCE_ARCHIVE_SHA256" \ + --cargo-lock-sha256 "$CARGO_LOCK_SHA256" \ + --version "$VERSION" \ + --target x86_64-unknown-linux-gnu \ + --profile release \ + --image-digest "$FEDORA_TOOL_BASE" \ + --baseline-executable target/release/solstone-linux \ + --artifact "$TAR_OUT" \ + --artifact "$RPM_OUT" \ + --output /release/.lane-evidence-handoff.json \ && rm /release/VERSION FROM scratch AS rpm diff --git a/packaging/release-policy.toml b/packaging/release-policy.toml new file mode 100644 index 0000000..dea0444 --- /dev/null +++ b/packaging/release-policy.toml @@ -0,0 +1,34 @@ +build_ubuntu = "sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdbcc71c442d890" +build_fedora = "sha256:1eea7f82474ec19ef359ee5a5896014df434cd44c0d6ba2b937ffbe0697dec56" +proof_debian = "sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdbcc71c442d890" +proof_rpm = "sha256:1eea7f82474ec19ef359ee5a5896014df434cd44c0d6ba2b937ffbe0697dec56" +proof_tar = "sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdbcc71c442d890" + +# Operator-owned values: verify these inside the provisioned digest images and +# commit any corrections before selecting EXPECTED_RELEASE_COMMIT. +[debian-amd64] +image_digest = "sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdbcc71c442d890" +os_release = "Ubuntu 22.04.5 LTS" +package_manager_version = "Debian 'dpkg' package management program version 1.21.1 (amd64)." +install_command = ["dpkg", "--root=/proof-root", "--install", "/input/solstone-linux_1.0.0-1_amd64.deb"] +version_command = ["/usr/bin/solstone-linux", "--version"] +executable_path = "/usr/bin/solstone-linux" +executable_mode = 493 + +[rpm-x86_64] +image_digest = "sha256:1eea7f82474ec19ef359ee5a5896014df434cd44c0d6ba2b937ffbe0697dec56" +os_release = "Fedora Linux 42 (Container Image)" +package_manager_version = "RPM version 4.20.1" +install_command = ["rpm", "--root", "/proof-root", "--install", "/input/solstone-linux-1.0.0-1.x86_64.rpm"] +version_command = ["/usr/bin/solstone-linux", "--version"] +executable_path = "/usr/bin/solstone-linux" +executable_mode = 493 + +[tar-x86_64] +image_digest = "sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdbcc71c442d890" +os_release = "Ubuntu 22.04.5 LTS" +package_manager_version = "installer portable-tar" +install_command = ["/input/install.sh", "--prefix", "/proof-root", "/input/solstone-linux-1.0.0-linux-x86_64.tar.gz"] +version_command = ["/bin/solstone-linux", "--version"] +executable_path = "/bin/solstone-linux" +executable_mode = 493 diff --git a/scripts/build-release.sh b/scripts/build-release.sh old mode 100755 new mode 100644 index 0b9f94e..67fe024 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -2,15 +2,15 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc # -# Build the Rust release rail in its distro-native packaging container. +# Build one non-candidate package lane for drift inspection. set -euo pipefail usage() { cat <<'EOF' Usage: scripts/build-release.sh [x86_64] -Build the requested native package and the portable baseline tarball into -dist/rust/. Only x86_64 is currently supported. +Build one package lane into the fixed dist/rust-drift/ directory. This helper +produces drift evidence only; it cannot create or promote a release candidate. EOF } @@ -21,97 +21,91 @@ fi FORMAT="$1" ARCH="${2:-$(uname -m)}" - case "$FORMAT" in deb|rpm) ;; *) - echo "error: unsupported package format '$FORMAT'; expected deb or rpm" >&2 - usage >&2 + echo "error: package format mismatch: expected deb or rpm, actual '$FORMAT'" >&2 + echo "repair: scripts/build-release.sh [x86_64]" >&2 exit 2 ;; esac - if [[ "$ARCH" != "x86_64" ]]; then - echo "error: unsupported architecture '$ARCH'; this release rail only builds x86_64" >&2 + echo "error: package architecture mismatch: expected x86_64, actual '$ARCH'" >&2 + echo "repair: run this drift helper on x86_64" >&2 exit 2 fi REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { - echo "error: run this script from a solstone-linux Git checkout" >&2 + echo "error: repository root mismatch: expected solstone-linux Git checkout, actual unavailable" >&2 + echo "repair: run from the solstone-linux checkout" >&2 exit 1 } cd "$REPO_ROOT" - if ! git diff --quiet HEAD || [[ -n "$(git status --porcelain)" ]]; then - echo "error: working tree dirty; commit or stash changes before building release artifacts" >&2 + echo "error: working tree mismatch: expected clean, actual dirty" >&2 + echo "repair: commit or restore changes before building drift evidence" >&2 exit 1 fi if command -v podman >/dev/null 2>&1; then - ENGINE="podman" + ENGINE=(podman build --pull=never --network=none) elif command -v docker >/dev/null 2>&1; then - ENGINE="docker" + docker buildx version >/dev/null 2>&1 || { + echo "error: Docker build capability mismatch: expected buildx, actual unavailable" >&2 + echo "repair: provision Docker buildx before building drift evidence" >&2 + exit 1 + } + ENGINE=(docker buildx build --pull=false --network=none) else - echo "error: podman or docker is required to build Rust release artifacts" >&2 + echo "error: container engine mismatch: expected podman or docker, actual unavailable" >&2 + echo "repair: provision a supported local container engine" >&2 exit 1 fi +policy_value() { + sed -n "s/^$1 = \"\([^\"]*\)\"/\1/p" packaging/release-policy.toml +} +UBUNTU_TOOL_BASE=$(policy_value build_ubuntu) +FEDORA_TOOL_BASE=$(policy_value build_fedora) +if [[ -z "$UBUNTU_TOOL_BASE" || -z "$FEDORA_TOOL_BASE" ]]; then + echo "error: release image policy mismatch: expected committed build digests, actual missing" >&2 + echo "repair: restore packaging/release-policy.toml from the release commit" >&2 + exit 1 +fi + +SOURCE_COMMIT=$(git rev-parse HEAD) +SOURCE_ARCHIVE_SHA256=$(git archive --format=tar HEAD | sha256sum | cut -d' ' -f1) +CARGO_LOCK_SHA256=$(sha256sum Cargo.lock | cut -d' ' -f1) +RELEASE_VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) +INVOCATION_ID=${SOURCE_COMMIT:0:32} OUTPUT_TMP=$(mktemp -d) trap 'rm -rf "$OUTPUT_TMP"' EXIT -# Keep the root CWD: cargo-generate-rpm resolves asset sources there first. -# Deb packaging stays inside Ubuntu because openSUSE hosts lack dpkg-shlibdeps; -# cargo-deb's depends="$auto" must be resolved in the Debian container. -"$ENGINE" build \ +"${ENGINE[@]}" \ --file packaging/Containerfile \ --target "$FORMAT" \ --output "type=local,dest=$OUTPUT_TMP" \ + --build-arg "UBUNTU_TOOL_BASE=$UBUNTU_TOOL_BASE" \ + --build-arg "FEDORA_TOOL_BASE=$FEDORA_TOOL_BASE" \ + --build-arg "INVOCATION_ID=$INVOCATION_ID" \ + --build-arg "SOURCE_COMMIT=$SOURCE_COMMIT" \ + --build-arg "SOURCE_ARCHIVE_SHA256=$SOURCE_ARCHIVE_SHA256" \ + --build-arg "CARGO_LOCK_SHA256=$CARGO_LOCK_SHA256" \ + --build-arg "RELEASE_VERSION=$RELEASE_VERSION" \ "$REPO_ROOT" +OUTPUT_DIR=dist/rust-drift +mkdir -p "$OUTPUT_DIR" shopt -s nullglob -TARBALLS=("$OUTPUT_TMP"/solstone-linux-*-linux-x86_64.tar.gz) -case "$FORMAT" in - deb) PACKAGES=("$OUTPUT_TMP"/solstone-linux_*-1_amd64.deb) ;; - rpm) PACKAGES=("$OUTPUT_TMP"/solstone-linux-*-1.x86_64.rpm) ;; -esac +OUTPUTS=("$OUTPUT_TMP"/* "$OUTPUT_TMP"/.[!.]*) shopt -u nullglob - -if [[ ${#TARBALLS[@]} -ne 1 || ${#PACKAGES[@]} -ne 1 ]]; then - echo "error: container output must contain exactly one baseline tarball and one $FORMAT package" >&2 +if [[ ${#OUTPUTS[@]} -ne 3 ]]; then + echo "error: drift output inventory mismatch: expected two artifacts and lane evidence, actual ${#OUTPUTS[@]}" >&2 + echo "repair: inspect the selected local build image and packaging/Containerfile" >&2 exit 1 fi - -TARBALL_VERSION=${TARBALLS[0]##*/solstone-linux-} -TARBALL_VERSION=${TARBALL_VERSION%-linux-x86_64.tar.gz} -case "$FORMAT" in - deb) EXPECTED_PACKAGE="solstone-linux_${TARBALL_VERSION}-1_amd64.deb" ;; - rpm) EXPECTED_PACKAGE="solstone-linux-${TARBALL_VERSION}-1.x86_64.rpm" ;; -esac -if [[ "${PACKAGES[0]##*/}" != "$EXPECTED_PACKAGE" ]]; then - echo "error: package and tarball versions do not match" >&2 - exit 1 -fi - -check_artifact() { - local source="$1" - local destination="dist/rust/${source##*/}" - if [[ -e "$destination" ]] && ! cmp -s "$source" "$destination"; then - echo "error: refusing to overwrite existing artifact with different bytes: $destination" >&2 - exit 1 - fi -} - -mkdir -p dist/rust -ARTIFACTS=("${TARBALLS[0]}" "${PACKAGES[0]}") -for ARTIFACT in "${ARTIFACTS[@]}"; do - check_artifact "$ARTIFACT" -done -for ARTIFACT in "${ARTIFACTS[@]}"; do - DESTINATION="dist/rust/${ARTIFACT##*/}" - if [[ -e "$DESTINATION" ]]; then - echo "keeping byte-identical existing artifact: $DESTINATION" - else - install -m 0644 "$ARTIFACT" "$DESTINATION" - fi +for SOURCE in "${OUTPUTS[@]}"; do + install -m 0644 "$SOURCE" "$OUTPUT_DIR/${SOURCE##*/}" done -echo "built Rust $FORMAT artifacts for version $TARBALL_VERSION in dist/rust/" +echo "built non-candidate $FORMAT drift evidence in $OUTPUT_DIR/" +echo "candidate status: unavailable; use make release-candidate for the atomic candidate transaction" diff --git a/vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json b/vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json new file mode 100644 index 0000000..dd68e7b --- /dev/null +++ b/vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://solpbc.org/schemas/rust-release-candidate-ledger/v1.json", + "title": "sol pbc Rust release candidate ledger", + "type": "object", + "additionalProperties": false, + "required": ["schema_version","product","version","source","validator","target","policy","advisory_cohort","images","tools","payload","package_members","expected_proof_ids","candidate_digest"], + "properties": { + "schema_version": {"const": 1}, + "product": {"const": "solstone-linux"}, + "version": {"type":"string","minLength":1}, + "source": {"type":"object","additionalProperties":false,"required":["commit","archive_sha256","cargo_lock_sha256"],"properties":{"commit":{"type":"string","pattern":"^(?:[0-9a-f]{40}|[0-9a-f]{64})$"},"archive_sha256":{"$ref":"#/$defs/sha256"},"cargo_lock_sha256":{"$ref":"#/$defs/sha256"}}}, + "validator": {"type":"object","additionalProperties":false,"required":["version"],"properties":{"version":{"type":"string","minLength":1}}}, + "target": {"type":"object","additionalProperties":false,"required":["triple","profile","features"],"properties":{"triple":{"const":"x86_64-unknown-linux-gnu"},"profile":{"const":"release"},"features":{"type":"array","items":{"type":"string"},"uniqueItems":true}}}, + "policy": {"type":"object","additionalProperties":false,"required":["cargo_deny_version","deterministic_gate","licenses_bans_sources","advisories","checked_at","active_exceptions"],"properties":{"cargo_deny_version":{"const":"0.20.2"},"deterministic_gate":{"const":"pass"},"licenses_bans_sources":{"const":"pass"},"advisories":{"const":"pass"},"checked_at":{"type":"string","format":"date-time"},"active_exceptions":{"type":"array","items":{"type":"string"},"uniqueItems":true}}}, + "advisory_cohort": {"type":"object","additionalProperties":false,"required":["source_id","commit","archive_sha256","acquired_at"],"properties":{"source_id":{"type":"string","minLength":1},"commit":{"type":"string","pattern":"^(?:[0-9a-f]{40}|[0-9a-f]{64})$"},"archive_sha256":{"$ref":"#/$defs/sha256"},"acquired_at":{"type":"string","format":"date-time"}}}, + "images": {"type":"object","additionalProperties":false,"required":["engine","engine_version","ubuntu_image_id","fedora_image_id"],"properties":{"engine":{"enum":["podman","docker"]},"engine_version":{"type":"string","minLength":1},"ubuntu_image_id":{"$ref":"#/$defs/image_id"},"fedora_image_id":{"$ref":"#/$defs/image_id"}}}, + "tools": {"type":"object","additionalProperties":{"type":"string","minLength":1}}, + "payload": {"type":"array","minItems":5,"maxItems":5,"items":{"$ref":"#/$defs/file"},"uniqueItems":true}, + "package_members": {"type":"array","minItems":3,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["package_file","format","installed_path","mode","bytes","sha256"],"properties":{"package_file":{"type":"string","minLength":1},"format":{"enum":["tar","deb","rpm"]},"installed_path":{"type":"string","minLength":1},"mode":{"type":"integer","minimum":0,"maximum":4095},"bytes":{"type":"integer","minimum":1},"sha256":{"$ref":"#/$defs/sha256"}}}}, + "expected_proof_ids": {"const":["debian-amd64","rpm-x86_64","tar-x86_64"]}, + "candidate_digest": {"$ref":"#/$defs/sha256"} + }, + "$defs": { + "sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"}, + "image_id":{"type":"string","pattern":"^sha256:[0-9a-f]{64}$"}, + "file":{"type":"object","additionalProperties":false,"required":["path","bytes","sha256"],"properties":{"path":{"type":"string","minLength":1},"bytes":{"type":"integer","minimum":1},"sha256":{"$ref":"#/$defs/sha256"}}} + } +} diff --git a/vendor/rust-release-candidate-proof/rust-release-candidate-proof.schema.json b/vendor/rust-release-candidate-proof/rust-release-candidate-proof.schema.json new file mode 100644 index 0000000..9a0d4d4 --- /dev/null +++ b/vendor/rust-release-candidate-proof/rust-release-candidate-proof.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://solpbc.org/schemas/rust-release-candidate-proof/v1.json", + "title": "sol pbc Rust release candidate proof", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "platform", + "candidate_digest", + "ledger_sha256", + "source_commit", + "cargo_lock_sha256", + "artifact_basename", + "artifact_bytes", + "artifact_sha256", + "proof_image_digest", + "os_release", + "package_manager_version", + "install_command", + "install_exit_status", + "version_command", + "version_exit_status", + "executable_path", + "executable_mode", + "executable_sha256", + "version_output", + "result", + "proof_time", + "architecture", + "network", + "isolation" + ], + "properties": { + "schema_version": {"const": 1}, + "platform": {"enum": ["debian-amd64", "rpm-x86_64", "tar-x86_64"]}, + "candidate_digest": {"$ref": "#/$defs/sha256"}, + "ledger_sha256": {"$ref": "#/$defs/sha256"}, + "source_commit": {"type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"}, + "cargo_lock_sha256": {"$ref": "#/$defs/sha256"}, + "artifact_basename": {"type": "string", "minLength": 1, "pattern": "^[^/\\\\]+$"}, + "artifact_bytes": {"type": "integer", "minimum": 1}, + "artifact_sha256": {"$ref": "#/$defs/sha256"}, + "proof_image_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "os_release": {"type": "string", "minLength": 1}, + "package_manager_version": {"type": "string", "minLength": 1}, + "install_command": {"$ref": "#/$defs/command"}, + "install_exit_status": {"type": "integer"}, + "version_command": {"$ref": "#/$defs/command"}, + "version_exit_status": {"type": "integer"}, + "executable_path": {"type": "string", "minLength": 1}, + "executable_mode": {"type": "integer", "minimum": 0, "maximum": 4095}, + "executable_sha256": {"$ref": "#/$defs/sha256"}, + "version_output": {"type": "string", "minLength": 1}, + "result": {"const": "pass"}, + "proof_time": {"type": "string", "format": "date-time"}, + "architecture": {"enum": ["amd64", "x86_64"]}, + "network": {"const": "none"}, + "isolation": {"type": "string", "minLength": 1}, + "dry_run_passed": {"type": "boolean"}, + "isolated_prefix_passed": {"type": "boolean"} + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "command": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + } + } +}