From f354c8f5a6f2f64b8dadcc71721037c37d053888 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 21 Jul 2026 08:37:02 -0600 Subject: [PATCH] fix(rust): confine release-candidate filesystem operations and require one shared package executable Candidate cleanup previously followed a git-ignored symlinked parent out of the checkout and could recursively delete an external directory: clear_candidate_paths removed dist/rust and dist/rust-evidence/ after only a bare symlink_metadata probe, while require_clean_tree canonicalized dist so a symlinked ignored output root passed the clean-tree check. Resolve every reserved release path through one boundary anchored to the canonical checkout directory identity, walk the complete component chain without following symlinks, require real directory ancestors and absent-or-exact-type leaves, and treat only NotFound as absence while permission, I/O, and metadata errors fail closed and remain distinguishable. Make one ReservedPath enumeration the sole production authority for reserved names and drive the confinement matrix from it. Remove pre-existing payload and evidence roots only after the production validators prove exact retained identity for this product and canonical version. Preserve and reject structurally partial, invalid, plausible, and same-name foreign roots with safe remediation. Preflight the complete cleanup set before mutation, then rename each target to a transaction-scoped quarantine and verify the moved inode before recursive deletion so post-validation replacement cannot redirect cleanup. Report residue with repository-relative reserved names. Nothing previously required the tar, Debian, and RPM packages to carry the same executable, so divergent executables could produce internally consistent evidence and a green candidate-proven status. Measure the baseline directly from the transaction-owned staged tar after lane reconciliation proves both lanes tar artifacts byte-identical, and cross-check lane-declared digests as declarations rather than measurement authority. Enforce in one validate_ledger point that all three final package members share one SHA-256 and byte count equal to the measured baseline, covering creation, retained validation, proof production and retention, status, missing-proof resume, and recovery. Add baseline_executable directly to the canonical ledger without an alias or legacy read path because no accepted candidate exists. Move the vendored schema, pinned schema digest, and echoed Make literal together. Exercise confinement through production commands at every reserved placement while proving external sentinels unchanged. Cover replacement, special-file, hardlink, and partial-cleanup inventory behavior at the pre-mutation barrier with the existing failure-injection closures, and falsify executable identity with tar, Debian, and RPM each serving as the sole divergent member. Co-Authored-By: OpenAI Codex --- Makefile | 2 +- RELEASING.md | 3 + .../src/boundary_tests.rs | 342 ++++++++ crates/rust-release-manifest/src/candidate.rs | 26 +- .../src/candidate_tests.rs | 130 ++- crates/rust-release-manifest/src/lib.rs | 826 ++++++++++++++++-- .../rust-release-manifest/src/proof_tests.rs | 342 ++++++-- crates/rust-release-manifest/src/tests.rs | 88 +- .../rust-release-manifest/src/transaction.rs | 716 ++++++++++----- .../rust-release-candidate-ledger.schema.json | 3 +- 10 files changed, 2080 insertions(+), 398 deletions(-) create mode 100644 crates/rust-release-manifest/src/boundary_tests.rs diff --git a/Makefile b/Makefile index fa8953f..feafbb6 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ 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 ledger schema SHA-256: 4b387f19d8018752c6d016a4c0c74343ed80d2b64a3ff9480aa75b04fa66882d" @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)); \ diff --git a/RELEASING.md b/RELEASING.md index 1ac1b4b..cc8bb0e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -125,6 +125,9 @@ Named-manifest validation does not imply candidate readiness. Directory classification requires exactly five regular files and rejects stale or extra entries. +Release roots must be real confined directories. The portable, Debian, and RPM +packages must contain the same executable bytes. + ## Non-candidate drift helper The individual lane helper is deliberately outside candidate state: diff --git a/crates/rust-release-manifest/src/boundary_tests.rs b/crates/rust-release-manifest/src/boundary_tests.rs new file mode 100644 index 0000000..4297ccf --- /dev/null +++ b/crates/rust-release-manifest/src/boundary_tests.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use std::cell::Cell; +use std::os::unix::fs::symlink; +use std::os::unix::net::UnixListener; + +fn payload_cleanup() -> ( + candidate_tests::TestRepo, + CleanupPlan, + PathBuf, + FileIdentity, +) { + let repo = candidate_tests::fixture(); + let payload = repo.root.path().join("dist/rust"); + fs::create_dir_all(&payload).unwrap(); + fs::write(payload.join("owned"), b"owned").unwrap(); + let identity = FileIdentity::from_metadata(&fs::symlink_metadata(&payload).unwrap()); + let plan = CleanupPlan::new(vec![CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: identity, + ownership: OwnershipEvidence::Created, + }]) + .unwrap(); + (repo, plan, payload, identity) +} + +#[test] +fn reserved_path_catalog_covers_every_variant_and_phase() { + let version = VersionComponent::new("1.0.0").unwrap(); + let transaction = TransactionComponent::new("0123456789abcdef").unwrap(); + let cases = ReservedPath::test_cases(version, transaction); + assert_eq!(cases.len(), 18); + let names = cases + .iter() + .map(|case| case.path.relative()) + .collect::>(); + assert_eq!(names.len(), cases.len()); + assert!(cases.iter().any(|case| { + case.path.relative() == Path::new("dist/rust") && case.expected == ExpectedLeaf::Directory + })); + assert!(cases.iter().any(|case| { + case.path.relative() == Path::new("dist/.rust-release-candidate.lock") + && case.expected == ExpectedLeaf::RegularFile + })); +} + +#[test] +fn cleanup_final_barrier_rejects_symlink_swap() { + let (repo, plan, payload, _) = payload_cleanup(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("sentinel"); + fs::write(&sentinel, b"external").unwrap(); + let displaced = repo.root.path().join("dist/displaced"); + let error = plan + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute_with( + |_, _| { + fs::rename(&payload, &displaced).unwrap(); + symlink(external.path(), &payload).unwrap(); + Ok(()) + }, + |_, _| Ok(()), + ) + .unwrap_err(); + assert!(error.to_string().contains("cleanup begun: false")); + assert_eq!(fs::read(sentinel).unwrap(), b"external"); + assert!(displaced.join("owned").is_file()); +} + +#[test] +fn cleanup_final_barrier_rejects_directory_swap() { + let (repo, plan, payload, _) = payload_cleanup(); + let displaced = repo.root.path().join("dist/displaced"); + let error = plan + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute_with( + |_, _| { + fs::rename(&payload, &displaced).unwrap(); + fs::create_dir(&payload).unwrap(); + fs::write(payload.join("foreign"), b"foreign").unwrap(); + Ok(()) + }, + |_, _| Ok(()), + ) + .unwrap_err(); + assert!(error.to_string().contains("owned identity")); + assert_eq!(fs::read(payload.join("foreign")).unwrap(), b"foreign"); + assert!(displaced.join("owned").is_file()); +} + +#[test] +fn cleanup_final_barrier_rejects_regular_file_swap() { + let (repo, plan, payload, _) = payload_cleanup(); + let displaced = repo.root.path().join("dist/displaced"); + let error = plan + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute_with( + |_, _| { + fs::rename(&payload, &displaced).unwrap(); + fs::write(&payload, b"foreign").unwrap(); + Ok(()) + }, + |_, _| Ok(()), + ) + .unwrap_err(); + assert!(error.to_string().contains("wrong type")); + assert_eq!(fs::read(payload).unwrap(), b"foreign"); +} + +#[test] +fn cleanup_final_barrier_rejects_foreign_same_name_swap() { + cleanup_final_barrier_rejects_directory_swap(); +} + +#[test] +fn cleanup_quarantine_identity_mismatch_is_preserved() { + let (repo, plan, payload, _) = payload_cleanup(); + let preserved = repo.root.path().join("dist/preserved-quarantine"); + let error = plan + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute_with( + |_, _| Ok(()), + |_, quarantine| { + fs::rename(quarantine, &preserved).unwrap(); + fs::create_dir(quarantine).unwrap(); + fs::write(quarantine.join("foreign"), b"foreign").unwrap(); + Ok(()) + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("owned identity")); + assert_eq!(fs::read(preserved.join("owned")).unwrap(), b"owned"); + assert_eq!( + fs::read( + repo.root + .path() + .join("dist") + .read_dir() + .unwrap() + .find_map(|entry| { + let path = entry.unwrap().path(); + path.join("foreign").is_file().then(|| path.join("foreign")) + }) + .unwrap() + ) + .unwrap(), + b"foreign" + ); + assert!(!payload.exists()); +} + +#[test] +fn cleanup_rejects_fifo_leaf_without_blocking() { + let repo = candidate_tests::fixture(); + fs::create_dir(repo.root.path().join("dist")).unwrap(); + let lock = repo.root.path().join("dist/.rust-release-candidate.lock"); + assert!( + Command::new("mkfifo") + .arg(&lock) + .status() + .unwrap() + .success() + ); + let error = ReservedReleaseBoundary::new(&repo.root) + .resolve_for_read(ReservedPath::Lock, ExpectedLeaf::RegularFile) + .unwrap_err(); + assert!(error.to_string().contains("wrong type")); +} + +#[test] +fn cleanup_rejects_unix_socket_leaf_without_connecting() { + let repo = candidate_tests::fixture(); + fs::create_dir(repo.root.path().join("dist")).unwrap(); + let lock = repo.root.path().join("dist/.rust-release-candidate.lock"); + let _listener = UnixListener::bind(&lock).unwrap(); + let error = ReservedReleaseBoundary::new(&repo.root) + .resolve_for_read(ReservedPath::Lock, ExpectedLeaf::RegularFile) + .unwrap_err(); + assert!(error.to_string().contains("wrong type")); +} + +#[test] +fn cleanup_does_not_modify_external_hardlink_sentinel() { + let repo = candidate_tests::fixture(); + fs::create_dir(repo.root.path().join("dist")).unwrap(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("sentinel"); + fs::write(&sentinel, b"external").unwrap(); + let lock = repo.root.path().join("dist/.rust-release-candidate.lock"); + fs::hard_link(&sentinel, &lock).unwrap(); + let identity = FileIdentity::from_metadata(&fs::symlink_metadata(&lock).unwrap()); + CleanupPlan::new(vec![CleanupEntry { + path: ReservedPath::Lock, + expected_type: ExpectedLeaf::RegularFile, + expected_identity: identity, + ownership: OwnershipEvidence::Created, + }]) + .unwrap() + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute() + .unwrap(); + assert_eq!(fs::read(sentinel).unwrap(), b"external"); +} + +#[test] +fn cleanup_mid_failure_reports_attempted_deleted_preserved_and_residual() { + let (repo, _, payload, payload_identity) = payload_cleanup(); + let version = VersionComponent::new("1.0.0").unwrap(); + let evidence = repo.root.path().join("dist/rust-evidence/1.0.0"); + fs::create_dir_all(&evidence).unwrap(); + let evidence_identity = FileIdentity::from_metadata(&fs::symlink_metadata(&evidence).unwrap()); + let plan = CleanupPlan::new(vec![ + CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: payload_identity, + ownership: OwnershipEvidence::Created, + }, + CleanupEntry { + path: ReservedPath::EvidenceVersion(version), + expected_type: ExpectedLeaf::Directory, + expected_identity: evidence_identity, + ownership: OwnershipEvidence::Created, + }, + ]) + .unwrap(); + let barriers = Cell::new(0); + let error = plan + .preflight(ReservedReleaseBoundary::new(&repo.root)) + .unwrap() + .execute_with( + |_, _| { + barriers.set(barriers.get() + 1); + if barriers.get() == 2 { + Err(Error::new("controlled cleanup I/O failure")) + } else { + Ok(()) + } + }, + |_, _| Ok(()), + ) + .unwrap_err() + .to_string(); + assert!(error.starts_with("controlled cleanup I/O failure\n")); + assert!(error.contains("attempted=[dist/rust,dist/rust-evidence/1.0.0]")); + assert!(error.contains("deleted=[dist/rust]")); + assert!(error.contains("residual=[dist/rust-evidence/1.0.0]")); + assert!(!payload.exists()); + assert!(evidence.is_dir()); +} + +#[test] +fn cleanup_rejects_plausible_invalid_payload_and_evidence() { + let repo = candidate_tests::fixture(); + let payload = repo.root.path().join("dist/rust"); + let evidence = repo.root.path().join("dist/rust-evidence/1.0.0"); + fs::create_dir_all(&payload).unwrap(); + fs::create_dir_all(&evidence).unwrap(); + fs::write(payload.join("plausible"), b"foreign payload").unwrap(); + fs::write(evidence.join("ledger.json"), b"{}").unwrap(); + let processes = ProcessEnvironment::default(); + let error = create_candidate( + &repo.root, + &repo.commit, + Path::new("unused-advisory-descriptor"), + &processes, + ) + .unwrap_err() + .to_string(); + assert!(error.starts_with("existing release candidate ownership mismatch:")); + assert_eq!( + fs::read(payload.join("plausible")).unwrap(), + b"foreign payload" + ); + assert_eq!(fs::read(evidence.join("ledger.json")).unwrap(), b"{}"); +} + +#[test] +fn production_create_rejects_symlinked_cleanup_ancestor_without_external_mutation() { + let repo = candidate_tests::fixture(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("sentinel"); + fs::write(&sentinel, b"external").unwrap(); + fs::create_dir(repo.root.path().join("dist")).unwrap(); + symlink(external.path(), repo.root.path().join("dist/rust")).unwrap(); + fs::create_dir_all(repo.root.path().join("dist/rust-evidence/1.0.0")).unwrap(); + let error = create_candidate( + &repo.root, + &repo.commit, + Path::new("unused-advisory-descriptor"), + &ProcessEnvironment::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.starts_with("existing release candidate ownership mismatch:")); + assert_eq!(fs::read(sentinel).unwrap(), b"external"); +} + +#[test] +fn production_recover_and_status_reject_symlinked_retained_roots_without_mutation() { + let fixture = proof_tests::retained_fixture(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("sentinel"); + fs::write(&sentinel, b"external").unwrap(); + let proofs = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs"); + let retained = fixture.repo.root.path().join("dist/retained-proofs"); + fs::rename(&proofs, &retained).unwrap(); + symlink(external.path(), &proofs).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()); + assert_eq!(fs::read(sentinel).unwrap(), b"external"); + assert!(retained.is_dir()); +} + +#[test] +fn production_status_rejects_symlinked_proof_leaf_without_external_mutation() { + let fixture = proof_tests::retained_fixture(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("sentinel"); + fs::write(&sentinel, b"external").unwrap(); + let proof = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs/debian-amd64.json"); + fs::remove_file(&proof).unwrap(); + symlink(&sentinel, &proof).unwrap(); + assert!(candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).is_err()); + assert_eq!(fs::read(sentinel).unwrap(), b"external"); +} diff --git a/crates/rust-release-manifest/src/candidate.rs b/crates/rust-release-manifest/src/candidate.rs index 742496b..d5121fc 100644 --- a/crates/rust-release-manifest/src/candidate.rs +++ b/crates/rust-release-manifest/src/candidate.rs @@ -1027,10 +1027,7 @@ pub fn build_lane(request: &LaneRequest<'_>) -> Result { 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"), + ReservedReleaseBoundary::new(request.repo).path(ReservedPath::StagingParent), ) { return Err(Error::new( @@ -1389,7 +1386,8 @@ pub fn reconcile_lanes( rpm: &LaneEvidence, deb_root: &Path, rpm_root: &Path, -) -> Result<()> { + version: &str, +) -> 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", @@ -1429,7 +1427,23 @@ pub fn reconcile_lanes( "lane tar mismatch: expected byte-identical, actual different", )); } - Ok(()) + let member = package_member_evidence(&deb_root.join(&deb_tar.path), version)?; + if member.sha256 != deb.baseline_executable_sha256 { + return Err(Error::new(format!( + "lane baseline executable mismatch: expected staged tar executable sha256 {}, actual deb declaration {}", + member.sha256, deb.baseline_executable_sha256 + ))); + } + if member.sha256 != rpm.baseline_executable_sha256 { + return Err(Error::new(format!( + "lane baseline executable mismatch: expected staged tar executable sha256 {}, actual rpm declaration {}", + member.sha256, rpm.baseline_executable_sha256 + ))); + } + Ok(ExecutableIdentity { + sha256: member.sha256, + bytes: member.bytes, + }) } pub fn recheck_images( diff --git a/crates/rust-release-manifest/src/candidate_tests.rs b/crates/rust-release-manifest/src/candidate_tests.rs index cc6f3ff..0946821 100644 --- a/crates/rust-release-manifest/src/candidate_tests.rs +++ b/crates/rust-release-manifest/src/candidate_tests.rs @@ -10,6 +10,20 @@ use std::sync::OnceLock; static ARCHIVE: OnceLock> = OnceLock::new(); +fn staged_baseline(staging: &StagingLayout) -> ExecutableIdentity { + let member = package_member_evidence( + &staging + .deb_lane + .join("solstone-linux-1.0.0-linux-x86_64.tar.gz"), + "1.0.0", + ) + .unwrap(); + ExecutableIdentity { + sha256: member.sha256, + bytes: member.bytes, + } +} + pub(super) struct TestRepo { _temp: tempfile::TempDir, pub root: RepoRoot, @@ -305,23 +319,47 @@ fn staging_construction_failure_cleans_only_owned_root_and_reports_residue() { fs::create_dir(&sibling).unwrap(); fs::write(sibling.join("canary"), b"foreign").unwrap(); + let transaction = TransactionComponent::new("owned-mid-failure").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()); + let identity = FileIdentity::from_metadata(&fs::symlink_metadata(&owned).unwrap()); + assert!( + StagingLayout::initialize_reserved(&repo.root, owned.clone(), transaction, identity) + .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 transaction = TransactionComponent::new("owned-cleanup-failure").unwrap(); 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(); + let identity = FileIdentity::from_metadata(&fs::symlink_metadata(&residue).unwrap()); + let error = + StagingLayout::initialize_reserved(&repo.root, residue.clone(), transaction, identity) + .unwrap_err(); assert!(error.to_string().contains("Permission denied")); - assert!(error.to_string().contains("repair: remove")); + assert!(error.to_string().contains("repair: inspect only")); + assert!( + !error + .to_string() + .contains(repo.root.path().to_str().unwrap()) + ); + let residue = fs::read_dir(&parent) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .contains("quarantine") + }) + .unwrap(); + fs::set_permissions(&residue, fs::Permissions::from_mode(0o755)).unwrap(); assert!(residue.exists()); assert_eq!(fs::read(sibling.join("canary")).unwrap(), b"foreign"); fs::remove_dir_all(residue).unwrap(); @@ -329,24 +367,41 @@ fn staging_construction_failure_cleans_only_owned_root_and_reports_residue() { #[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 repo = fixture(); + let payload = repo.root.path().join("dist/rust"); + let evidence = repo.root.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::write(proofs.join("debian-amd64.json"), b"proof").unwrap(); + let unowned = repo + .root + .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( + let payload_identity = FileIdentity::from_metadata(&fs::symlink_metadata(&payload).unwrap()); + let evidence_identity = FileIdentity::from_metadata(&fs::symlink_metadata(&evidence).unwrap()); + let error = CleanupPlan::new(vec![ + CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: payload_identity, + ownership: OwnershipEvidence::Promoted, + }, + CleanupEntry { + path: ReservedPath::EvidenceVersion(VersionComponent::new("1.0.0").unwrap()), + expected_type: ExpectedLeaf::Directory, + expected_identity: evidence_identity, + ownership: OwnershipEvidence::Published, + }, + ]) + .unwrap() + .finish_error( + ReservedReleaseBoundary::new(&repo.root), Error::new("controlled failure"), - &payload, - &evidence, - &[owned], ); assert_eq!(error.to_string(), "controlled failure"); assert!(!payload.exists()); @@ -356,17 +411,29 @@ fn controlled_rollback_removes_only_owned_payload_and_evidence() { #[test] fn controlled_rollback_reports_exact_residue() { - let temp = tempfile::tempdir().unwrap(); - let payload = temp.path().join("dist/rust"); + let repo = fixture(); + let payload = repo.root.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(); + fs::write(payload.join("owned"), b"owned").unwrap(); + fs::set_permissions(&payload, fs::Permissions::from_mode(0o555)).unwrap(); + let identity = FileIdentity::from_metadata(&fs::symlink_metadata(&payload).unwrap()); + let error = CleanupPlan::new(vec![CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: identity, + ownership: OwnershipEvidence::Promoted, + }]) + .unwrap() + .finish_error( + ReservedReleaseBoundary::new(&repo.root), + Error::new("controlled failure"), + ); + assert!(error.to_string().starts_with("controlled failure\n")); + assert!(error.to_string().contains("residual=[dist/.rust.")); assert!( - error + !error .to_string() - .contains(&format!("residue at {}", payload.display())) + .contains(repo.root.path().to_str().unwrap()) ); assert!(error.to_string().contains("repair:")); } @@ -516,7 +583,7 @@ fn executable(path: &Path, body: &str) { fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); } -fn process_bin( +pub(super) fn process_bin( cargo_body: &str, podman_body: Option<&str>, ) -> (tempfile::TempDir, ProcessEnvironment) { @@ -591,7 +658,7 @@ pub(super) fn current_time() -> String { Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -const CARGO_DENY_ASSERTIONS: &str = r#"#!/bin/sh +pub(super) 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 "*) ;; @@ -985,6 +1052,7 @@ fn finalize_candidate_rolls_back_post_promotion_image_recheck_failure() { version: "1.0.0", deb: &deb, rpm: &rpm, + baseline_executable: staged_baseline(&staging), cohort: &cohort, images: &images, engine: ContainerEngine::Podman, @@ -994,8 +1062,7 @@ fn finalize_candidate_rolls_back_post_promotion_image_recheck_failure() { 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(); + let error = finish_candidate_staging_owned::<()>(&repo.root, &staging, Err(error)).unwrap_err(); assert!(error.to_string().contains("image")); assert!(!error.to_string().contains("candidate-proven")); assert!(!staging.root.exists()); @@ -1049,6 +1116,7 @@ fn finalize_candidate_rolls_back_post_promotion_ledger_write_failure() { version: "1.0.0", deb: &deb, rpm: &rpm, + baseline_executable: staged_baseline(&staging), cohort: &cohort, images: &images, engine: ContainerEngine::Podman, @@ -1059,8 +1127,7 @@ fn finalize_candidate_rolls_back_post_promotion_ledger_write_failure() { 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(); + let error = finish_candidate_staging_owned::<()>(&repo.root, &staging, 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()); @@ -1119,6 +1186,7 @@ fn finalize_candidate_rolls_back_promoted_classification_failure() { version: "1.0.0", deb: &deb, rpm: &rpm, + baseline_executable: staged_baseline(&staging), cohort: &cohort, images: &images, engine: ContainerEngine::Podman, @@ -1129,8 +1197,7 @@ fn finalize_candidate_rolls_back_promoted_classification_failure() { 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(); + let error = finish_candidate_staging_owned::<()>(&repo.root, &staging, 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()); @@ -1264,6 +1331,7 @@ esac version: "1.0.0", deb: &deb, rpm: &rpm, + baseline_executable: staged_baseline(staging), cohort: &cohort, images: &images, engine: ContainerEngine::Podman, @@ -1975,7 +2043,7 @@ fn lane_evidence_rejects_stale_swapped_crosswired_and_tar_mismatch() { serde_json::to_vec(&deb).unwrap(), ) .unwrap(); - assert!(reconcile_lanes(&deb, &rpm, &staging.deb_lane, &staging.rpm_lane).is_err()); + assert!(reconcile_lanes(&deb, &rpm, &staging.deb_lane, &staging.rpm_lane, "1.0.0",).is_err()); } #[test] diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index 69a588c..3a1e32a 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Cursor, Read}; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt, symlink}; @@ -37,7 +37,7 @@ pub const MANIFEST_OK_MESSAGE: &str = pub const RELEASE_DIR_OK_MESSAGE: &str = "Release directory verified as a complete five-file candidate."; pub const LEDGER_SCHEMA_SHA256: &str = - "c93e189b2e7bc1c65d38f52f924c74a101a4b3f39acbe73ba626b4f59e180533"; + "4b387f19d8018752c6d016a4c0c74343ed80d2b64a3ff9480aa75b04fa66882d"; pub const PROOF_SCHEMA_SHA256: &str = "3009eab983eea832961220406f19c7459ed1db7fffc352af6ffaf664f9cd7dcf"; @@ -203,8 +203,30 @@ impl std::error::Error for Error {} type Result = std::result::Result; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileIdentity { + pub(crate) device: u64, + pub(crate) inode: u64, +} + +impl FileIdentity { + fn from_metadata(metadata: &fs::Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + } + } +} + +fn same_file_identity(metadata: &fs::Metadata, identity: FileIdentity) -> bool { + metadata.dev() == identity.device && metadata.ino() == identity.inode +} + #[derive(Clone, Debug)] -pub struct RepoRoot(PathBuf); +pub struct RepoRoot { + path: PathBuf, + identity: FileIdentity, +} impl RepoRoot { pub fn resolve() -> Result { @@ -243,14 +265,640 @@ impl RepoRoot { "workspace layout mismatch: expected release workspace members, actual incomplete", )); } - Ok(Self(root)) + let metadata = fs::symlink_metadata(&root).map_err(display_error)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(Error::new( + "repository root mismatch: expected no-follow directory, actual other", + )); + } + Ok(Self { + path: root, + identity: FileIdentity::from_metadata(&metadata), + }) } pub fn path(&self) -> &Path { - &self.0 + &self.path + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct VersionComponent(String); + +impl VersionComponent { + pub(crate) fn new(value: &str) -> Result { + validate_version(value)?; + portable_path_component(value)?; + Ok(Self(value.to_owned())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TransactionComponent(String); + +impl TransactionComponent { + pub(crate) fn new(value: &str) -> Result { + portable_path_component(value)?; + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(Error::new( + "candidate transaction mismatch: expected portable identifier, actual invalid", + )); + } + Ok(Self(value.to_owned())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProofId(String); + +impl ProofId { + pub(crate) fn new(value: &str) -> Result { + proof_spec(value)?; + portable_path_component(value)?; + Ok(Self(value.to_owned())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ReservedPath { + Dist, + Payload, + EvidenceParent, + EvidenceVersion(VersionComponent), + EvidenceLedger(VersionComponent), + Lock, + StagingParent, + StagingInvocation(TransactionComponent), + StagingContext(TransactionComponent), + StagingDebLane(TransactionComponent), + StagingRpmLane(TransactionComponent), + StagingAdvisoryDb(TransactionComponent), + StagingPayload(TransactionComponent), + Proofs(VersionComponent), + Proof(VersionComponent, ProofId), + ProofAttempt(VersionComponent, ProofId, TransactionComponent), + ProofAttemptOutput(VersionComponent, ProofId, TransactionComponent), + Quarantine(Box, TransactionComponent), +} + +impl ReservedPath { + pub(crate) fn components(&self) -> Vec { + let fixed = |items: &[&str]| items.iter().map(OsString::from).collect(); + match self { + Self::Dist => fixed(&["dist"]), + Self::Payload => fixed(&["dist", "rust"]), + Self::EvidenceParent => fixed(&["dist", "rust-evidence"]), + Self::EvidenceVersion(version) => { + let mut value = fixed(&["dist", "rust-evidence"]); + value.push((&version.0).into()); + value + } + Self::EvidenceLedger(version) => { + let mut value = Self::EvidenceVersion(version.clone()).components(); + value.push("ledger.json".into()); + value + } + Self::Lock => fixed(&["dist", ".rust-release-candidate.lock"]), + Self::StagingParent => fixed(&["dist", ".rust-release-candidate-staging"]), + Self::StagingInvocation(transaction) => { + let mut value = Self::StagingParent.components(); + value.push((&transaction.0).into()); + value + } + Self::StagingContext(transaction) => { + child(Self::StagingInvocation(transaction.clone()), "context") + } + Self::StagingDebLane(transaction) => { + child(Self::StagingInvocation(transaction.clone()), "lane-deb") + } + Self::StagingRpmLane(transaction) => { + child(Self::StagingInvocation(transaction.clone()), "lane-rpm") + } + Self::StagingAdvisoryDb(transaction) => { + child(Self::StagingInvocation(transaction.clone()), "advisory-db") + } + Self::StagingPayload(transaction) => { + child(Self::StagingInvocation(transaction.clone()), "payload") + } + Self::Proofs(version) => child(Self::EvidenceVersion(version.clone()), "proofs"), + Self::Proof(version, proof) => { + child(Self::Proofs(version.clone()), &format!("{}.json", proof.0)) + } + Self::ProofAttempt(version, proof, transaction) => child( + Self::Proofs(version.clone()), + &format!(".{}.{}.attempt", proof.0, transaction.0), + ), + Self::ProofAttemptOutput(version, proof, transaction) => child( + Self::ProofAttempt(version.clone(), proof.clone(), transaction.clone()), + "proof.json", + ), + Self::Quarantine(path, transaction) => { + let mut value = path.components(); + let name = value.pop().expect("reserved paths are nonempty"); + value.push( + format!(".{}.{}.quarantine", name.to_string_lossy(), transaction.0).into(), + ); + value + } + } + } + + pub(crate) fn relative(&self) -> PathBuf { + self.components().into_iter().collect() + } + + #[cfg(test)] + pub(crate) fn test_cases( + version: VersionComponent, + transaction: TransactionComponent, + ) -> Vec { + let proof = ProofId::new("debian-amd64").expect("fixed proof ID"); + let paths = vec![ + Self::Dist, + Self::Payload, + Self::EvidenceParent, + Self::EvidenceVersion(version.clone()), + Self::EvidenceLedger(version.clone()), + Self::Lock, + Self::StagingParent, + Self::StagingInvocation(transaction.clone()), + Self::StagingContext(transaction.clone()), + Self::StagingDebLane(transaction.clone()), + Self::StagingRpmLane(transaction.clone()), + Self::StagingAdvisoryDb(transaction.clone()), + Self::StagingPayload(transaction.clone()), + Self::Proofs(version.clone()), + Self::Proof(version.clone(), proof.clone()), + Self::ProofAttempt(version.clone(), proof.clone(), transaction.clone()), + Self::ProofAttemptOutput(version, proof, transaction.clone()), + Self::Quarantine(Box::new(Self::Payload), transaction), + ]; + paths + .into_iter() + .map(|path| { + let expected = match &path { + Self::EvidenceLedger(_) + | Self::Lock + | Self::Proof(_, _) + | Self::ProofAttemptOutput(_, _, _) => ExpectedLeaf::RegularFile, + Self::Dist + | Self::Payload + | Self::EvidenceParent + | Self::EvidenceVersion(_) + | Self::StagingParent + | Self::StagingInvocation(_) + | Self::StagingContext(_) + | Self::StagingDebLane(_) + | Self::StagingRpmLane(_) + | Self::StagingAdvisoryDb(_) + | Self::StagingPayload(_) + | Self::Proofs(_) + | Self::ProofAttempt(_, _, _) + | Self::Quarantine(_, _) => ExpectedLeaf::Directory, + }; + ReservedPathCase { path, expected } + }) + .collect() + } +} + +#[cfg(test)] +pub(crate) struct ReservedPathCase { + pub(crate) path: ReservedPath, + pub(crate) expected: ExpectedLeaf, +} + +fn child(parent: ReservedPath, name: &str) -> Vec { + let mut value = parent.components(); + value.push(name.into()); + value +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ExpectedLeaf { + Absent, + Directory, + RegularFile, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MetadataState { + Absent, + Present(FileIdentity, bool, bool), +} + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedReservedPath { + pub(crate) relative: PathBuf, + pub(crate) absolute: PathBuf, + pub(crate) parent_identity: FileIdentity, + pub(crate) identity: Option, +} + +pub(crate) struct ReservedReleaseBoundary<'a> { + root: &'a RepoRoot, +} + +impl<'a> ReservedReleaseBoundary<'a> { + pub(crate) fn new(root: &'a RepoRoot) -> Self { + Self { root } + } + + fn metadata(&self, path: &Path, relative: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(MetadataState::Present( + FileIdentity::from_metadata(&metadata), + metadata.is_dir() && !metadata.file_type().is_symlink(), + metadata.is_file() && !metadata.file_type().is_symlink(), + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(MetadataState::Absent), + Err(error) => Err(Error::new(format!( + "release boundary metadata mismatch: expected readable {}, actual {:?} error\nrepair: inspect only the named reserved path", + relative.display(), + error.kind() + ))), + } + } + + fn resolve( + &self, + reserved: ReservedPath, + expected: ExpectedLeaf, + ) -> Result { + let root_metadata = fs::symlink_metadata(self.root.path()).map_err(display_error)?; + if !root_metadata.is_dir() || !same_file_identity(&root_metadata, self.root.identity) { + return Err(Error::new( + "release boundary anchor mismatch: expected original checkout directory, actual replaced", + )); + } + let components = reserved.components(); + let relative: PathBuf = components.iter().collect(); + let mut current = self.root.path().to_owned(); + let mut parent_identity = self.root.identity; + for (index, component) in components.iter().enumerate() { + portable_path_component(&component.to_string_lossy())?; + let parent_before = fs::symlink_metadata(¤t).map_err(display_error)?; + if !parent_before.is_dir() || !same_file_identity(&parent_before, parent_identity) { + return Err(boundary_type_error( + &relative, + "stable directory ancestor", + "replaced", + false, + )); + } + current.push(component); + let component_relative: PathBuf = components[..=index].iter().collect(); + let state = self.metadata(¤t, &component_relative)?; + let parent_after = + fs::symlink_metadata(current.parent().unwrap()).map_err(display_error)?; + if !same_file_identity(&parent_after, parent_identity) { + return Err(boundary_type_error( + &relative, + "stable directory ancestor", + "replaced", + false, + )); + } + let leaf = index + 1 == components.len(); + match (leaf, state) { + (false, MetadataState::Present(identity, true, _)) => parent_identity = identity, + (false, MetadataState::Absent) => { + return Err(boundary_type_error( + &component_relative, + "directory", + "absent", + false, + )); + } + (false, _) => { + return Err(boundary_type_error( + &component_relative, + "directory", + "non-directory or symlink", + false, + )); + } + (true, MetadataState::Absent) if expected == ExpectedLeaf::Absent => { + return Ok(ResolvedReservedPath { + relative, + absolute: current, + parent_identity, + identity: None, + }); + } + (true, MetadataState::Present(identity, true, _)) + if expected == ExpectedLeaf::Directory => + { + return Ok(ResolvedReservedPath { + relative, + absolute: current, + parent_identity, + identity: Some(identity), + }); + } + (true, MetadataState::Present(identity, _, true)) + if expected == ExpectedLeaf::RegularFile => + { + return Ok(ResolvedReservedPath { + relative, + absolute: current, + parent_identity, + identity: Some(identity), + }); + } + (true, MetadataState::Absent) => { + return Err(boundary_type_error( + &relative, + leaf_name(expected), + "absent", + false, + )); + } + (true, _) => { + return Err(boundary_type_error( + &relative, + leaf_name(expected), + "wrong type or symlink", + false, + )); + } + } + } + unreachable!() + } + + pub(crate) fn resolve_for_read( + &self, + path: ReservedPath, + leaf: ExpectedLeaf, + ) -> Result { + self.resolve(path, leaf) + } + pub(crate) fn resolve_for_create( + &self, + path: ReservedPath, + leaf: ExpectedLeaf, + ) -> Result { + self.resolve(path, leaf) + } + pub(crate) fn resolve_for_replace( + &self, + path: ReservedPath, + leaf: ExpectedLeaf, + expected: FileIdentity, + ) -> Result { + self.resolve_identity(path, leaf, expected) + } + pub(crate) fn resolve_for_delete( + &self, + path: ReservedPath, + leaf: ExpectedLeaf, + expected: FileIdentity, + ) -> Result { + self.resolve_identity(path, leaf, expected) + } + + fn resolve_identity( + &self, + path: ReservedPath, + leaf: ExpectedLeaf, + expected: FileIdentity, + ) -> Result { + let resolved = self.resolve(path, leaf)?; + if resolved.identity != Some(expected) { + return Err(boundary_type_error( + &resolved.relative, + "owned identity", + "replaced", + false, + )); + } + Ok(resolved) + } + + pub(crate) fn path(&self, path: ReservedPath) -> PathBuf { + self.root.path().join(path.relative()) + } +} + +fn leaf_name(expected: ExpectedLeaf) -> &'static str { + match expected { + ExpectedLeaf::Absent => "absent leaf", + ExpectedLeaf::Directory => "directory", + ExpectedLeaf::RegularFile => "regular file", + } +} + +fn boundary_type_error(path: &Path, expected: &str, actual: &str, cleanup_begun: bool) -> Error { + Error::new(format!( + "release boundary {} mismatch: expected {expected}, actual {actual}; cleanup begun: {cleanup_begun}\nrepair: inspect only the named reserved path", + path.display() + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OwnershipEvidence { + Created, + Promoted, + Published, + RetainedCandidate, +} + +#[derive(Clone, Debug)] +pub(crate) struct CleanupEntry { + pub(crate) path: ReservedPath, + pub(crate) expected_type: ExpectedLeaf, + pub(crate) expected_identity: FileIdentity, + pub(crate) ownership: OwnershipEvidence, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CleanupReport { + pub(crate) attempted: Vec, + pub(crate) deleted: Vec, + pub(crate) preserved: Vec, + pub(crate) residual: Vec, +} + +pub(crate) struct CleanupPlan { + entries: Vec, + transaction: TransactionComponent, +} + +pub(crate) struct ValidatedCleanupPlan<'a> { + boundary: ReservedReleaseBoundary<'a>, + entries: Vec, + transaction: TransactionComponent, +} + +impl CleanupPlan { + pub(crate) fn new(entries: Vec) -> Result { + Ok(Self { + entries, + transaction: TransactionComponent::new(&transaction_id()?)?, + }) + } + + pub(crate) fn preflight<'a>( + self, + boundary: ReservedReleaseBoundary<'a>, + ) -> Result> { + let mut names = BTreeSet::new(); + for entry in &self.entries { + let relative = entry.path.relative(); + if !names.insert(relative.clone()) { + return Err(boundary_type_error( + &relative, + "unique cleanup target", + "duplicate", + false, + )); + } + if names.iter().any(|other| { + other != &relative && (other.starts_with(&relative) || relative.starts_with(other)) + }) { + return Err(boundary_type_error( + &relative, + "non-overlapping cleanup target", + "ancestor or descendant overlap", + false, + )); + } + let _ = entry.ownership; + boundary.resolve_for_delete( + entry.path.clone(), + entry.expected_type, + entry.expected_identity, + )?; + } + Ok(ValidatedCleanupPlan { + boundary, + entries: self.entries, + transaction: self.transaction, + }) + } + + pub(crate) fn finish_error( + self, + boundary: ReservedReleaseBoundary<'_>, + original: Error, + ) -> Error { + match self + .preflight(boundary) + .and_then(ValidatedCleanupPlan::execute) + { + Ok(_) => original, + Err(cleanup) => Error::new(format!("{original}\n{cleanup}")), + } + } +} + +impl ValidatedCleanupPlan<'_> { + pub(crate) fn execute(self) -> Result { + self.execute_with(|_, _| Ok(()), |_, _| Ok(())) + } + + pub(crate) fn execute_with( + self, + mut final_barrier: impl FnMut(&ReservedPath, &Path) -> Result<()>, + mut quarantine_barrier: impl FnMut(&ReservedPath, &Path) -> Result<()>, + ) -> Result { + let mut report = CleanupReport::default(); + for (index, entry) in self.entries.iter().enumerate() { + report.attempted.push(entry.path.clone()); + let mut residual_path = entry.path.clone(); + let result = (|| { + let resolved = self.boundary.resolve_for_delete( + entry.path.clone(), + entry.expected_type, + entry.expected_identity, + )?; + final_barrier(&entry.path, &resolved.absolute)?; + let resolved = self.boundary.resolve_for_delete( + entry.path.clone(), + entry.expected_type, + entry.expected_identity, + )?; + let parent = fs::symlink_metadata( + resolved + .absolute + .parent() + .expect("reserved path has parent"), + ) + .map_err(display_error)?; + if !same_file_identity(&parent, resolved.parent_identity) { + return Err(boundary_type_error( + &resolved.relative, + "stable parent identity", + "replaced", + true, + )); + } + let quarantine = ReservedPath::Quarantine( + Box::new(entry.path.clone()), + self.transaction.clone(), + ); + let quarantine_path = self + .boundary + .resolve_for_create(quarantine.clone(), ExpectedLeaf::Absent)? + .absolute; + // Safe std pathname APIs leave a transient rename exposure to a hostile + // privileged parent replacement; parent-FD syscalls are out of scope. + fs::rename(&resolved.absolute, &quarantine_path).map_err(display_error)?; + residual_path = quarantine.clone(); + quarantine_barrier(&quarantine, &quarantine_path)?; + let quarantined = self.boundary.resolve_for_delete( + quarantine.clone(), + entry.expected_type, + entry.expected_identity, + )?; + match entry.expected_type { + ExpectedLeaf::Directory => { + fs::remove_dir_all(&quarantined.absolute).map_err(display_error)? + } + ExpectedLeaf::RegularFile => { + fs::remove_file(&quarantined.absolute).map_err(display_error)? + } + ExpectedLeaf::Absent => unreachable!("cleanup entries are present"), + } + report.deleted.push(entry.path.clone()); + Ok(()) + })(); + if let Err(error) = result { + report.residual.push(residual_path); + report.preserved.extend( + self.entries[index + 1..] + .iter() + .map(|item| item.path.clone()), + ); + return Err(cleanup_error(error, &report)); + } + } + Ok(report) } } +fn cleanup_error(error: Error, report: &CleanupReport) -> Error { + let names = |paths: &[ReservedPath]| { + paths + .iter() + .map(|path| path.relative().display().to_string()) + .collect::>() + .join(",") + }; + Error::new(format!( + "{error}\ncleanup incomplete: attempted=[{}]; deleted=[{}]; preserved=[{}]; residual=[{}]\nrepair: inspect only the preserved and residual reserved paths", + names(&report.attempted), + names(&report.deleted), + names(&report.preserved), + names(&report.residual) + )) +} + #[derive(Debug)] pub struct CandidateLock { path: PathBuf, @@ -260,9 +908,22 @@ pub struct CandidateLock { 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 boundary = ReservedReleaseBoundary::new(root); + let dist = boundary.path(ReservedPath::Dist); + match fs::symlink_metadata(&dist) { + Ok(_) => { + boundary.resolve_for_read(ReservedPath::Dist, ExpectedLeaf::Directory)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + boundary.resolve_for_create(ReservedPath::Dist, ExpectedLeaf::Absent)?; + fs::create_dir(&dist).map_err(display_error)?; + boundary.resolve_for_read(ReservedPath::Dist, ExpectedLeaf::Directory)?; + } + Err(error) => return Err(display_error(error)), + } + let path = boundary + .resolve_for_create(ReservedPath::Lock, ExpectedLeaf::Absent)? + .absolute; let file = OpenOptions::new() .write(true) .create_new(true) @@ -331,57 +992,104 @@ pub struct StagingLayout { pub rpm_lane: PathBuf, pub advisory_db: PathBuf, pub payload: PathBuf, + pub(crate) transaction: Option, + pub(crate) root_identity: FileIdentity, } 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)?; + let transaction = TransactionComponent::new(&transaction_id()?)?; + let boundary = ReservedReleaseBoundary::new(root); + let parent = boundary.path(ReservedPath::StagingParent); + if parent.symlink_metadata().is_ok() { + boundary.resolve_for_read(ReservedPath::StagingParent, ExpectedLeaf::Directory)?; + } else { + boundary.resolve_for_create(ReservedPath::StagingParent, ExpectedLeaf::Absent)?; + fs::create_dir(&parent).map_err(display_error)?; + } + let staging = boundary + .resolve_for_create( + ReservedPath::StagingInvocation(transaction.clone()), + ExpectedLeaf::Absent, + )? + .absolute; fs::create_dir(&staging).map_err(display_error)?; - Self::initialize_owned(staging) - } - - pub(crate) fn initialize_owned(staging: PathBuf) -> Result { + let root_identity = boundary + .resolve_for_read( + ReservedPath::StagingInvocation(transaction.clone()), + ExpectedLeaf::Directory, + )? + .identity + .expect("present staging identity"); + Self::initialize_reserved(root, staging, transaction, root_identity) + } + + pub(crate) fn initialize_reserved( + root: &RepoRoot, + staging: PathBuf, + transaction: TransactionComponent, + root_identity: FileIdentity, + ) -> Result { + let boundary = ReservedReleaseBoundary::new(root); + boundary.resolve_for_replace( + ReservedPath::StagingInvocation(transaction.clone()), + ExpectedLeaf::Directory, + root_identity, + )?; 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"), - root: staging.clone(), + context: boundary.path(ReservedPath::StagingContext(transaction.clone())), + deb_lane: boundary.path(ReservedPath::StagingDebLane(transaction.clone())), + rpm_lane: boundary.path(ReservedPath::StagingRpmLane(transaction.clone())), + advisory_db: boundary.path(ReservedPath::StagingAdvisoryDb(transaction.clone())), + payload: boundary.path(ReservedPath::StagingPayload(transaction.clone())), + root: staging, + transaction: Some(transaction.clone()), + root_identity, }; let setup = (|| { - for directory in [ - &layout.context, - &layout.deb_lane, - &layout.rpm_lane, - &layout.advisory_db, - &layout.payload, + for (reserved, directory) in [ + ( + ReservedPath::StagingContext(transaction.clone()), + &layout.context, + ), + ( + ReservedPath::StagingDebLane(transaction.clone()), + &layout.deb_lane, + ), + ( + ReservedPath::StagingRpmLane(transaction.clone()), + &layout.rpm_lane, + ), + ( + ReservedPath::StagingAdvisoryDb(transaction.clone()), + &layout.advisory_db, + ), + ( + ReservedPath::StagingPayload(transaction.clone()), + &layout.payload, + ), ] { - fs::create_dir_all(directory).map_err(display_error)?; + boundary.resolve_for_create(reserved, ExpectedLeaf::Absent)?; + fs::create_dir(directory).map_err(display_error)?; } - Ok(layout) + Ok(()) })(); 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() - ))), - }, + Ok(()) => Ok(layout), + Err(primary) => { + let cleanup = CleanupPlan::new(vec![CleanupEntry { + path: ReservedPath::StagingInvocation(transaction), + expected_type: ExpectedLeaf::Directory, + expected_identity: root_identity, + ownership: OwnershipEvidence::Created, + }]) + .and_then(|plan| plan.preflight(boundary)) + .and_then(ValidatedCleanupPlan::execute); + match cleanup { + Ok(_) => Err(primary), + Err(cleanup) => Err(Error::new(format!("{primary}\n{cleanup}"))), + } + } } } } @@ -1991,29 +2699,9 @@ fn ordered_exceptions(repo: &RepoRoot) -> Result> { fn require_clean_tree(root: &Path, payload_root: &Path) -> Result<()> { let root = root.canonicalize().map_err(display_error)?; - 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", - )); - } let ignored = Command::new("git") .args(["check-ignore", "--quiet", "--"]) - .arg(&payload_root) + .arg(payload_root) .current_dir(&root) .status() .map_err(display_error)?; @@ -2398,6 +3086,8 @@ fn display_error(error: impl std::fmt::Display) -> Error { Error::new(error.to_string()) } +#[cfg(test)] +mod boundary_tests; #[cfg(test)] mod candidate_tests; #[cfg(test)] diff --git a/crates/rust-release-manifest/src/proof_tests.rs b/crates/rust-release-manifest/src/proof_tests.rs index 076dd62..3b24ae2 100644 --- a/crates/rust-release-manifest/src/proof_tests.rs +++ b/crates/rust-release-manifest/src/proof_tests.rs @@ -2,7 +2,7 @@ // Copyright (c) 2026 sol pbc use super::*; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::os::unix::fs::PermissionsExt; const CANDIDATE_VECTOR: &str = "27e7dd62da4e0022b755f669dd00118a57715aaf088ff7f2a6c322951238494e"; const BUNDLE_VECTOR: &str = "cd214a005b2186a7eb25e9fd756561fb9c6e47e02004047c1cd5132106580a3e"; @@ -13,25 +13,34 @@ fn proof_ids() -> [&'static str; 3] { #[test] fn failed_proof_attempt_removes_only_owned_attempt_and_publication() { - let temp = tempfile::tempdir().unwrap(); - let attempt = temp.path().join("attempt"); - let published = temp.path().join("proof.json"); - let foreign = temp.path().join("foreign.tmp"); + let repo = crate::candidate_tests::fixture(); + let version = VersionComponent::new("1.0.0").unwrap(); + let proof = ProofId::new("debian-amd64").unwrap(); + let transaction = TransactionComponent::new("proof-cleanup-test").unwrap(); + let attempt_reserved = ReservedPath::ProofAttempt(version.clone(), proof.clone(), transaction); + let published_reserved = ReservedPath::Proof(version, proof); + let boundary = ReservedReleaseBoundary::new(&repo.root); + let attempt = boundary.path(attempt_reserved.clone()); + let published = boundary.path(published_reserved.clone()); + let foreign = repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs/foreign.tmp"); + fs::create_dir_all(attempt.parent().unwrap()).unwrap(); fs::create_dir(&attempt).unwrap(); fs::write(attempt.join("partial"), b"partial").unwrap(); fs::write(&published, b"published").unwrap(); fs::write(&foreign, b"foreign").unwrap(); - let attempt_metadata = fs::symlink_metadata(&attempt).unwrap(); - let published_metadata = fs::symlink_metadata(&published).unwrap(); - let error = cleanup_proof_attempt( + let attempt_identity = FileIdentity::from_metadata(&fs::symlink_metadata(&attempt).unwrap()); + let published_identity = + FileIdentity::from_metadata(&fs::symlink_metadata(&published).unwrap()); + let error = finish_proof_attempt_cleanup( + &repo.root, Error::new("primary"), - &attempt, - (attempt_metadata.dev(), attempt_metadata.ino()), - &published, - Some(FileIdentity { - device: published_metadata.dev(), - inode: published_metadata.ino(), - }), + attempt_reserved, + attempt_identity, + published_reserved, + Some(published_identity), ); assert_eq!(error.to_string(), "primary"); assert!(!attempt.exists()); @@ -55,6 +64,28 @@ fn candidate_schemas_are_digest_and_identity_pinned() { ledger_schema["properties"]["expected_proof_ids"]["const"], serde_json::json!(ids) ); + assert!( + ledger_schema["required"] + .as_array() + .unwrap() + .contains(&Value::String("baseline_executable".into())) + ); + assert_eq!( + ledger_schema["properties"]["baseline_executable"]["required"], + serde_json::json!(["sha256", "bytes"]) + ); + assert_eq!( + ledger_schema["properties"]["baseline_executable"]["additionalProperties"], + false + ); + assert_eq!( + ledger_schema["properties"]["baseline_executable"]["properties"]["sha256"]["pattern"], + "^[0-9a-f]{64}$" + ); + assert_eq!( + ledger_schema["properties"]["baseline_executable"]["properties"]["bytes"]["minimum"], + 1 + ); } fn payload_vector() -> (tempfile::TempDir, Vec) { @@ -476,6 +507,10 @@ fn fixed_payload_and_ledger_serialization_are_reproducible() { tools: BTreeMap::new(), payload, package_members: Vec::new(), + baseline_executable: ExecutableIdentity { + sha256: "0".repeat(64), + bytes: 1, + }, expected_proof_ids: proof_ids().map(str::to_owned).to_vec(), candidate_digest: candidate, }; @@ -492,23 +527,38 @@ fn fixed_payload_and_ledger_serialization_are_reproducible() { assert!(serde_json::from_value::(foreign).is_err()); } -struct RetainedFixture { - repo: crate::candidate_tests::TestRepo, +pub(super) struct RetainedFixture { + pub(super) repo: crate::candidate_tests::TestRepo, advisory_db: tempfile::TempDir, _descriptor_dir: tempfile::TempDir, descriptor: PathBuf, - ledger: CandidateLedger, - ledger_bytes: Vec, + pub(super) ledger: CandidateLedger, + pub(super) ledger_bytes: Vec, } -fn retained_fixture() -> RetainedFixture { +pub(super) fn retained_fixture() -> RetainedFixture { retained_fixture_from(crate::candidate_tests::fixture()) } fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixture { + retained_fixture_from_products(repo, crate::tests::release_fixture(), true) +} + +fn retained_fixture_with(executables: [&[u8]; 3]) -> RetainedFixture { + retained_fixture_from_products( + crate::candidate_tests::fixture(), + crate::tests::release_fixture_with(executables), + false, + ) +} + +fn retained_fixture_from_products( + repo: crate::candidate_tests::TestRepo, + products: tempfile::TempDir, + validate: bool, +) -> 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(); @@ -559,6 +609,10 @@ fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixt "fedora_image_digest".into(), fedora.strip_prefix("sha256:").unwrap().into(), ); + let baseline_executable = ExecutableIdentity { + sha256: digest(crate::tests::FIXTURE_EXECUTABLE_BYTES), + bytes: crate::tests::FIXTURE_EXECUTABLE_BYTES.len() as u64, + }; let ledger = CandidateLedger { schema_version: 1, product: PRODUCT.into(), @@ -605,10 +659,15 @@ fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixt tools, payload: artifacts.clone(), package_members: members, + baseline_executable, 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 ledger_bytes = if validate { + ledger_bytes(&repo.root, &payload, &ledger).unwrap() + } else { + canonical_json(&serde_json::to_value(&ledger).unwrap()).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(); @@ -625,6 +684,178 @@ fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixt } } +const DIVERGENT_EXECUTABLE: &[u8] = b"plausible alternate executable"; + +fn divergent_executables(index: usize) -> [&'static [u8]; 3] { + let mut executables = [crate::tests::FIXTURE_EXECUTABLE_BYTES; 3]; + executables[index] = DIVERGENT_EXECUTABLE; + executables +} + +fn creation_rejects_divergence(index: usize) { + let fixture = retained_fixture_with(divergent_executables(index)); + let payload = fixture.repo.root.path().join("dist/rust"); + let lock = CandidateLock::acquire(&fixture.repo.root).unwrap(); + let staging = StagingLayout::create(&fixture.repo.root, &lock).unwrap(); + let context = export_immutable_context(&fixture.repo.root, &staging.context).unwrap(); + let descriptor_dir = tempfile::tempdir().unwrap(); + let descriptor = crate::candidate_tests::descriptor( + descriptor_dir.path(), + fixture.advisory_db.path(), + None, + &crate::candidate_tests::current_time(), + ); + let (_bin, processes) = + crate::candidate_tests::process_bin(crate::candidate_tests::CARGO_DENY_ASSERTIONS, None); + let cohort = run_advisory_cohort(&context, &staging, &descriptor, &processes).unwrap(); + let policy = ReleaseImages::from_root(fixture.repo.root.path()).unwrap(); + let members = fixture.ledger.package_members.clone(); + let error = construct_ledger(LedgerInput { + root: &fixture.repo.root, + context: &context, + version: "1.0.0", + payload_root: &payload, + package_members: members, + baseline_executable: fixture.ledger.baseline_executable.clone(), + cohort: &cohort, + ubuntu: &proof_image_identity(&policy.build_ubuntu), + fedora: &proof_image_identity(&policy.build_fedora), + engine: ContainerEngine::Podman, + engine_identity: "podman version 5.8.3".into(), + tools: fixture.ledger.tools.clone(), + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("candidate executable identity mismatch") + ); +} + +fn retained_rejects_divergence(index: usize) { + let fixture = retained_fixture_with(divergent_executables(index)); + let payload = fixture.repo.root.path().join("dist/rust"); + let error = validate_ledger(&fixture.repo.root, &payload, &fixture.ledger).unwrap_err(); + assert!( + error + .to_string() + .contains("candidate executable identity mismatch") + ); +} + +#[test] +fn creation_rejects_divergent_tar_executable() { + creation_rejects_divergence(0); +} + +#[test] +fn creation_rejects_divergent_deb_executable() { + creation_rejects_divergence(1); +} + +#[test] +fn creation_rejects_divergent_rpm_executable() { + creation_rejects_divergence(2); +} + +#[test] +fn retained_validation_rejects_divergent_tar_executable() { + retained_rejects_divergence(0); +} + +#[test] +fn retained_validation_rejects_divergent_deb_executable() { + retained_rejects_divergence(1); +} + +#[test] +fn retained_validation_rejects_divergent_rpm_executable() { + retained_rejects_divergence(2); +} + +#[test] +fn resume_rejects_each_divergent_package_executable() { + for index in 0..3 { + let fixture = retained_fixture_with(divergent_executables(index)); + assert!( + prove_candidate( + &fixture.repo.root, + "1.0.0", + &fixture.descriptor, + &ProcessEnvironment::default(), + ) + .unwrap_err() + .to_string() + .contains("candidate executable identity mismatch") + ); + } +} + +#[test] +fn status_rejects_each_divergent_package_executable() { + for index in 0..3 { + let fixture = retained_fixture_with(divergent_executables(index)); + assert!( + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes,) + .unwrap_err() + .to_string() + .contains("candidate executable identity mismatch") + ); + } +} + +#[test] +fn recovery_rejects_each_divergent_package_executable() { + for index in 0..3 { + let fixture = retained_fixture_with(divergent_executables(index)); + assert!( + recover_candidate(&fixture.repo.root, "1.0.0") + .unwrap_err() + .to_string() + .contains("candidate executable identity mismatch") + ); + } +} + +#[test] +fn ledger_rejects_baseline_digest_or_byte_count_drift() { + for field in ["sha256", "bytes"] { + let fixture = retained_fixture(); + let mut ledger = fixture.ledger; + if field == "sha256" { + ledger.baseline_executable.sha256 = "0".repeat(64); + } else { + ledger.baseline_executable.bytes += 1; + } + let payload = fixture.repo.root.path().join("dist/rust"); + assert!( + validate_ledger(&fixture.repo.root, &payload, &ledger) + .unwrap_err() + .to_string() + .contains("candidate executable baseline mismatch") + ); + } +} + +#[test] +fn lane_reconciliation_rejects_declared_baseline_not_matching_staged_tar() { + let repo = crate::candidate_tests::fixture(); + let templates = tempfile::tempdir().unwrap(); + docker_create_templates(&repo.root, templates.path()); + let mut deb: LaneEvidence = + serde_json::from_slice(&fs::read(templates.path().join("deb-lane.json")).unwrap()).unwrap(); + let mut rpm: LaneEvidence = + serde_json::from_slice(&fs::read(templates.path().join("rpm-lane.json")).unwrap()).unwrap(); + deb.baseline_executable_sha256 = "0".repeat(64); + rpm.baseline_executable_sha256 = "0".repeat(64); + assert!( + reconcile_lanes(&deb, &rpm, templates.path(), templates.path(), "1.0.0",) + .unwrap_err() + .to_string() + .contains("lane baseline executable mismatch") + ); +} + 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(); @@ -681,6 +912,8 @@ fn candidate_status_requires_three_fully_valid_proofs() { let status = candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).unwrap(); assert_eq!(status.status, "candidate-proven"); + assert!(status.local_evidence_only); + assert!(!status.publication_approval); let proof = fixture .repo .root @@ -1256,12 +1489,13 @@ fn create_readiness_ledger_replacement_rolls_back_promoted_candidate() { 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 finalized = FinalizedCandidate::new( + fixture.ledger.clone(), + fixture.ledger_bytes.clone(), + fixture.repo.root.path().join("dist/rust"), + fixture.repo.root.path().join("dist/rust-evidence/1.0.0"), + ) + .unwrap(); let readiness = candidate_status( &fixture.repo.root, &finalized.ledger, @@ -1294,33 +1528,41 @@ fn package_member_enumeration_propagates_entry_errors() { #[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 lock = CandidateLock::acquire(&fixture.repo.root).unwrap(); + let staging = StagingLayout::create(&fixture.repo.root, &lock).unwrap(); + let staging_parent = staging.root.parent().unwrap().to_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()); + finish_candidate_staging_owned(&fixture.repo.root, &staging, Ok(())).unwrap(); + assert!(!staging.root.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")), + let staging = StagingLayout::create(&fixture.repo.root, &lock).unwrap(); + let finalized = FinalizedCandidate::new( + fixture.ledger.clone(), + fixture.ledger_bytes.clone(), + fixture.repo.root.path().join("dist/rust"), + fixture.repo.root.path().join("dist/rust-evidence/1.0.0"), ) - .unwrap_err(); + .unwrap(); + let primary = + finish_created_candidate::<()>(&finalized, &[], Err(Error::new("primary failure"))) + .unwrap_err(); + let displaced = staging_parent.join("displaced"); + fs::rename(&staging.root, &displaced).unwrap(); + fs::write(&staging.root, b"foreign replacement").unwrap(); + let error = finish_candidate_staging_owned::<()>(&fixture.repo.root, &staging, Err(primary)) + .unwrap_err(); assert!(error.to_string().contains("primary failure")); - assert!(error.to_string().contains("repair: remove")); - assert!(residue.exists()); + assert!(error.to_string().contains("repair: inspect only")); + assert!( + !error + .to_string() + .contains(fixture.repo.root.path().to_str().unwrap()) + ); + assert_eq!(fs::read(&staging.root).unwrap(), b"foreign replacement"); + assert!(displaced.is_dir()); assert!(!fixture.repo.root.path().join("dist/rust").exists()); assert!( !fixture @@ -1504,6 +1746,8 @@ fn docker_create_templates(root: &RepoRoot, directory: &Path) { fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); } let policy = ReleaseImages::from_root(root.path()).unwrap(); + let tar = directory.join("solstone-linux-1.0.0-linux-x86_64.tar.gz"); + let baseline = package_member_evidence(&tar, "1.0.0").unwrap(); for (lane, image, name) in [ (Lane::Deb, &policy.build_ubuntu, "deb-lane.json"), (Lane::Rpm, &policy.build_fedora, "rpm-lane.json"), @@ -1524,7 +1768,7 @@ fn docker_create_templates(root: &RepoRoot, directory: &Path) { features: vec![], rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef0123456789abcdef0123456789abcdef01\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), + baseline_executable_sha256: baseline.sha256.clone(), image_digest: "@IMAGE@".into(), packaging_tool: match lane { Lane::Deb => "cargo-deb 3.7.0", diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 084f653..35d52cc 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -213,7 +213,9 @@ fn raw_tarball(root: &Path, name: &str, entries: &[RawTarEntry<'_>]) -> PathBuf gz_file(root, name, &tar_bytes(entries)) } -fn producer_tar_bytes(version: &str) -> Vec { +pub(super) const FIXTURE_EXECUTABLE_BYTES: &[u8] = b"fixture executable"; + +fn producer_tar_bytes_with(version: &str, executable: &[u8]) -> Vec { let member_root = format!("solstone-linux-{version}-linux-x86_64"); let names = [ format!("{member_root}/"), @@ -230,7 +232,13 @@ fn producer_tar_bytes(version: &str) -> Vec { .enumerate() .map(|(index, name)| RawTarEntry { name: name.as_bytes(), - body: if index < 4 { b"" } else { b"fixture" }, + body: if index < 4 { + b"" + } else if index == 4 { + executable + } else { + b"fixture" + }, entry_type: if index < 4 { b'5' } else { b'0' }, mode: if index == 4 { 0o755 } else { 0o644 }, link_name: b"", @@ -239,14 +247,22 @@ fn producer_tar_bytes(version: &str) -> Vec { tar_bytes(&entries) } -fn producer_tarball(root: &Path, version: &str) -> PathBuf { +fn producer_tar_bytes(version: &str) -> Vec { + producer_tar_bytes_with(version, FIXTURE_EXECUTABLE_BYTES) +} + +fn producer_tarball_with(root: &Path, version: &str, executable: &[u8]) -> PathBuf { gz_file( root, &format!("solstone-linux-{version}-linux-x86_64.tar.gz"), - &producer_tar_bytes(version), + &producer_tar_bytes_with(version, executable), ) } +fn producer_tarball(root: &Path, version: &str) -> PathBuf { + producer_tarball_with(root, version, FIXTURE_EXECUTABLE_BYTES) +} + fn control_tar(version: &str) -> Vec { let mut archive = tar::Builder::new(Vec::new()); let body = format!("Package: solstone-linux\nVersion: {version}-1\nArchitecture: amd64\n"); @@ -274,15 +290,14 @@ fn control_tar_bodies(bodies: &[String]) -> Vec { archive.into_inner().unwrap() } -fn data_tar() -> Vec { +fn data_tar(executable: &[u8]) -> 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_size(executable.len() as u64); header.set_mode(0o755); header.set_cksum(); archive - .append_data(&mut header, "./usr/bin/solstone-linux", &bytes[..]) + .append_data(&mut header, "./usr/bin/solstone-linux", executable) .unwrap(); archive.into_inner().unwrap() } @@ -303,7 +318,7 @@ fn deb_members(root: &Path, name: &str, members: &[(&str, &[u8])]) -> PathBuf { path } -fn deb(root: &Path, version: &str) -> PathBuf { +fn deb_with(root: &Path, version: &str, executable: &[u8]) -> PathBuf { let path = root.join(format!("solstone-linux_{version}-1_amd64.deb")); let mut archive = ar::Builder::new(File::create(&path).unwrap()); let marker = b"2.0\n"; @@ -314,17 +329,17 @@ 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 compressed = gzip(&data_tar(executable)); 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 { +fn rpm_file_with(root: &Path, version: &str, executable: &[u8]) -> 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(), + executable, rpm::FileOptions::new("/usr/bin/solstone-linux").permissions(0o755), ) .unwrap() @@ -337,27 +352,48 @@ fn rpm_file(root: &Path, version: &str) -> PathBuf { #[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(); + let mut members = artifact_paths(fixture.path(), "1.0.0") + .unwrap() + .into_iter() + .map(|path| package_member_evidence(&path, "1.0.0").unwrap()) + .collect::>(); + members.sort_by(|left, right| left.format.cmp(&right.format)); + for member in &members { 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")); + let expected_path = if member.format == "tar" { + "/bin/solstone-linux" + } else { + "/usr/bin/solstone-linux" + }; + assert_eq!(member.installed_path, expected_path); } + assert_eq!(members.len(), 3); + assert!( + members + .iter() + .all(|member| member.sha256 == digest(FIXTURE_EXECUTABLE_BYTES)) + ); + assert!( + members + .iter() + .all(|member| member.bytes == FIXTURE_EXECUTABLE_BYTES.len() as u64) + ); } pub(super) fn release_fixture() -> tempfile::TempDir { + release_fixture_with([ + FIXTURE_EXECUTABLE_BYTES, + FIXTURE_EXECUTABLE_BYTES, + FIXTURE_EXECUTABLE_BYTES, + ]) +} + +pub(super) fn release_fixture_with(executables: [&[u8]; 3]) -> tempfile::TempDir { let temp = tempfile::tempdir().unwrap(); let version = evidence().version; - producer_tarball(temp.path(), &version); - deb(temp.path(), &version); - rpm_file(temp.path(), &version); + producer_tarball_with(temp.path(), &version, executables[0]); + deb_with(temp.path(), &version, executables[1]); + rpm_file_with(temp.path(), &version, executables[2]); temp } diff --git a/crates/rust-release-manifest/src/transaction.rs b/crates/rust-release-manifest/src/transaction.rs index 3ed611d..b3b44ed 100644 --- a/crates/rust-release-manifest/src/transaction.rs +++ b/crates/rust-release-manifest/src/transaction.rs @@ -22,10 +22,18 @@ pub struct CandidateLedger { pub tools: BTreeMap, pub payload: Vec, pub package_members: Vec, + pub baseline_executable: ExecutableIdentity, pub expected_proof_ids: Vec, pub candidate_digest: String, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExecutableIdentity { + pub sha256: String, + pub bytes: u64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct LedgerSource { @@ -78,6 +86,7 @@ pub struct LedgerInput<'a> { pub version: &'a str, pub payload_root: &'a Path, pub package_members: Vec, + pub baseline_executable: ExecutableIdentity, pub cohort: &'a AdvisoryCohort, pub ubuntu: &'a ImageIdentity, pub fedora: &'a ImageIdentity, @@ -146,6 +155,7 @@ pub fn construct_ledger(input: LedgerInput<'_>) -> Result { tools: input.tools, payload, package_members: members, + baseline_executable: input.baseline_executable, expected_proof_ids: PROOF_SPECS.iter().map(|spec| spec.id.to_owned()).collect(), candidate_digest: candidate, }; @@ -242,31 +252,86 @@ pub fn validate_ledger( "ledger package member mismatch: expected package bytes, actual different", )); } + validate_shared_executable(ledger)?; + Ok(()) +} + +fn validate_shared_executable(ledger: &CandidateLedger) -> Result<()> { + let expected = [ + ( + "tar", + "/bin/solstone-linux", + format!("solstone-linux-{}-linux-x86_64.tar.gz", ledger.version), + ), + ( + "deb", + "/usr/bin/solstone-linux", + format!("solstone-linux_{}-1_amd64.deb", ledger.version), + ), + ( + "rpm", + "/usr/bin/solstone-linux", + format!("solstone-linux-{}-1.x86_64.rpm", ledger.version), + ), + ]; + let mut identities = Vec::new(); + for (format, installed_path, package_file) in expected { + let matches = ledger + .package_members + .iter() + .filter(|member| member.format == format) + .collect::>(); + if matches.len() != 1 + || matches[0].installed_path != installed_path + || matches[0].package_file != package_file + || matches[0].mode != 0o755 + { + return Err(Error::new( + "candidate executable inventory mismatch: expected documented tar, deb, and rpm members, actual different", + )); + } + identities.push((matches[0].sha256.as_str(), matches[0].bytes)); + } + if identities[1..] + .iter() + .any(|identity| *identity != identities[0]) + { + return Err(Error::new( + "candidate executable identity mismatch: expected one measured baseline across tar, deb, and rpm, actual divergent package members", + )); + } + let actual = identities[0]; + if actual.0 != ledger.baseline_executable.sha256 || actual.1 != ledger.baseline_executable.bytes + { + return Err(Error::new(format!( + "candidate executable baseline mismatch: expected measured baseline {}/{}, actual package identity {}/{}", + ledger.baseline_executable.sha256, ledger.baseline_executable.bytes, actual.0, actual.1 + ))); + } 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 version = VersionComponent::new(version)?; + let boundary = ReservedReleaseBoundary::new(root); + let path = boundary + .resolve_for_read( + ReservedPath::EvidenceLedger(version), + ExpectedLeaf::RegularFile, + )? + .absolute; 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)?; + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory)? + .absolute; + validate_ledger(root, &payload, &ledger)?; Ok((ledger, bytes)) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FileIdentity { - pub(crate) device: u64, - pub(crate) inode: u64, -} - pub fn atomic_write_0644(path: &Path, bytes: &[u8]) -> Result { atomic_write_0644_with_parent_sync(path, bytes, |parent| { File::open(parent) @@ -294,10 +359,9 @@ pub(crate) fn atomic_write_0644_with_post_rename( .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() - ))); + return Err(Error::new( + "atomic output mismatch: expected absent, actual present", + )); } let temp = parent.join(format!( ".{}.{}.tmp", @@ -348,8 +412,7 @@ fn reclaim_after_publish_failure( if same_file { fs::remove_file(path).map_err(|cleanup| { Error::new(format!( - "{error}\nerror: atomic output cleanup mismatch: expected published file absent, actual residue\nrepair: remove {} after confirming it belongs to the failed transaction: {cleanup}", - path.display() + "{error}\nerror: atomic output cleanup mismatch: expected published file absent, actual residue: {cleanup}\nrepair: inspect only the failed atomic output entry" )) })?; } @@ -358,17 +421,12 @@ fn reclaim_after_publish_failure( Ok(()) } -fn same_file_identity(metadata: &fs::Metadata, identity: FileIdentity) -> bool { - metadata.dev() == identity.device && metadata.ino() == identity.inode -} - 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() + "{error}\nerror: atomic output cleanup mismatch: expected owned temporary absent, actual residue: {cleanup}\nrepair: inspect only the failed transaction temporary entry" )) })?; } @@ -384,6 +442,7 @@ pub struct FinalizeInput<'a> { pub version: &'a str, pub deb: &'a LaneEvidence, pub rpm: &'a LaneEvidence, + pub baseline_executable: ExecutableIdentity, pub cohort: &'a AdvisoryCohort, pub images: &'a ResolvedImages, pub engine: ContainerEngine, @@ -396,15 +455,35 @@ pub struct FinalizedCandidate { pub ledger_bytes: Vec, pub payload_root: PathBuf, pub evidence_root: PathBuf, + payload_identity: FileIdentity, + evidence_identity: FileIdentity, +} + +impl FinalizedCandidate { + pub(crate) fn new( + ledger: CandidateLedger, + ledger_bytes: Vec, + payload_root: PathBuf, + evidence_root: PathBuf, + ) -> Result { + let payload_identity = FileIdentity::from_metadata( + &fs::symlink_metadata(&payload_root).map_err(display_error)?, + ); + let evidence_identity = FileIdentity::from_metadata( + &fs::symlink_metadata(&evidence_root).map_err(display_error)?, + ); + Ok(Self { + ledger, + ledger_bytes, + payload_root, + evidence_root, + payload_identity, + evidence_identity, + }) + } } 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)?; @@ -414,18 +493,25 @@ pub fn finalize_candidate(input: FinalizeInput<'_>) -> Result) -> Result) -> Result) -> Result Ok(value), - Err(error) => Err(rollback_error(error, &payload_root, &evidence_root, &[])), + Err(error) => { + let mut entries = vec![CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: payload_identity, + ownership: OwnershipEvidence::Promoted, + }]; + if let Some(identity) = evidence_identity { + entries.push(CleanupEntry { + path: ReservedPath::EvidenceVersion(version), + expected_type: ExpectedLeaf::Directory, + expected_identity: identity, + ownership: OwnershipEvidence::Published, + }); + } + match CleanupPlan::new(entries) + .and_then(|plan| plan.preflight(boundary)) + .and_then(ValidatedCleanupPlan::execute) + { + Ok(_) => Err(error), + Err(cleanup) => Err(Error::new(format!("{error}\n{cleanup}"))), + } + } } } @@ -591,62 +719,6 @@ fn recheck_source(root: &RepoRoot, context: &ImmutableContext, payload: &Path) - 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() { - match fs::read_dir(&proofs_root) { - Ok(mut entries) => match entries.next() { - None if fs::remove_dir(&proofs_root).is_err() => residues.push(proofs_root), - Some(Err(_)) => residues.push(proofs_root), - _ => {} - }, - Err(_) => residues.push(proofs_root), - } - } - if evidence_root.is_dir() { - match fs::read_dir(evidence_root) { - Ok(mut entries) => match entries.next() { - None if fs::remove_dir(evidence_root).is_err() => { - residues.push(evidence_root.to_owned()); - } - Some(Err(_)) => residues.push(evidence_root.to_owned()), - _ => {} - }, - 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 { @@ -692,30 +764,44 @@ pub struct ProofRequest<'a> { } pub fn produce_or_retain_proof(request: &ProofRequest<'_>) -> Result { - proof_spec(request.platform)?; - 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)); + let proof = ProofId::new(request.platform)?; + let version = VersionComponent::new(&request.ledger.version)?; + let boundary = ReservedReleaseBoundary::new(request.root); + let proofs_root = boundary.path(ReservedPath::Proofs(version.clone())); + match boundary.resolve_for_create(ReservedPath::Proofs(version.clone()), ExpectedLeaf::Absent) { + Ok(_) => fs::create_dir(&proofs_root).map_err(display_error)?, + Err(_) => { + boundary.resolve_for_read( + ReservedPath::Proofs(version.clone()), + ExpectedLeaf::Directory, + )?; + } + } + let final_reserved = ReservedPath::Proof(version.clone(), proof.clone()); + let final_path = boundary.path(final_reserved.clone()); if final_path.symlink_metadata().is_ok() { + boundary.resolve_for_read(final_reserved, ExpectedLeaf::RegularFile)?; validate_proof_file(request, &final_path)?; return Ok(final_path); } - let attempt = proofs_root.join(format!( - ".{}.{}.attempt", - request.platform, - transaction_id()? - )); + let transaction = TransactionComponent::new(&transaction_id()?)?; + let attempt_reserved = + ReservedPath::ProofAttempt(version.clone(), proof.clone(), transaction.clone()); + let attempt = boundary + .resolve_for_create(attempt_reserved.clone(), ExpectedLeaf::Absent)? + .absolute; fs::create_dir(&attempt).map_err(display_error)?; - let attempt_metadata = fs::symlink_metadata(&attempt).map_err(display_error)?; + let attempt_identity = boundary + .resolve_for_read(attempt_reserved.clone(), ExpectedLeaf::Directory)? + .identity + .expect("present proof attempt identity"); let mut published_identity = None; let result = (|| { let artifact = proof_artifact(request.ledger, request.platform)?; - let artifact_path = request.root.path().join("dist/rust").join(&artifact.path); + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory)? + .absolute; + let artifact_path = payload.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()); @@ -778,61 +864,83 @@ pub fn produce_or_retain_proof(request: &ProofRequest<'_>) -> Result { request.engine.executable(), &args, )?; - let produced = attempt.join("proof.json"); + let produced = boundary + .resolve_for_read( + ReservedPath::ProofAttemptOutput( + version.clone(), + proof.clone(), + transaction.clone(), + ), + ExpectedLeaf::RegularFile, + )? + .absolute; validate_proof_file(request, &produced)?; let bytes = fs::read(&produced).map_err(display_error)?; - fs::remove_dir_all(&attempt).map_err(display_error)?; + CleanupPlan::new(vec![CleanupEntry { + path: attempt_reserved.clone(), + expected_type: ExpectedLeaf::Directory, + expected_identity: attempt_identity, + ownership: OwnershipEvidence::Created, + }])? + .preflight(ReservedReleaseBoundary::new(request.root))? + .execute()?; published_identity = Some(atomic_write_0644(&final_path, &bytes)?); validate_proof_file(request, &final_path)?; Ok(final_path.clone()) })(); result.map_err(|error| { - cleanup_proof_attempt( + finish_proof_attempt_cleanup( + request.root, error, - &attempt, - (attempt_metadata.dev(), attempt_metadata.ino()), - &final_path, + attempt_reserved, + attempt_identity, + ReservedPath::Proof(version, proof), published_identity, ) }) } -pub(crate) fn cleanup_proof_attempt( +pub(crate) fn finish_proof_attempt_cleanup( + root: &RepoRoot, error: Error, - attempt: &Path, - attempt_identity: (u64, u64), - published: &Path, + attempt: ReservedPath, + attempt_identity: FileIdentity, + published: ReservedPath, published_identity: Option, ) -> Error { - let mut residue = Vec::new(); - if same_inode(attempt, attempt_identity) && fs::remove_dir_all(attempt).is_err() { - residue.push(attempt.to_owned()); + let boundary = ReservedReleaseBoundary::new(root); + let mut entries = Vec::new(); + if fs::symlink_metadata(boundary.path(attempt.clone())) + .is_ok_and(|metadata| same_file_identity(&metadata, attempt_identity)) + { + entries.push(CleanupEntry { + path: attempt, + expected_type: ExpectedLeaf::Directory, + expected_identity: attempt_identity, + ownership: OwnershipEvidence::Created, + }); } if let Some(identity) = published_identity - && fs::symlink_metadata(published) + && fs::symlink_metadata(boundary.path(published.clone())) .is_ok_and(|metadata| same_file_identity(&metadata, identity)) - && fs::remove_file(published).is_err() { - residue.push(published.to_owned()); + entries.push(CleanupEntry { + path: published, + expected_type: ExpectedLeaf::RegularFile, + expected_identity: identity, + ownership: OwnershipEvidence::Published, + }); } - if residue.is_empty() { + if entries.is_empty() { error } else { - Error::new(format!( - "{error}\nerror: proof attempt cleanup mismatch: expected owned paths absent, actual residue at {}\nrepair: remove only the named failed-attempt paths", - residue - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", ") - )) + match CleanupPlan::new(entries) { + Ok(plan) => plan.finish_error(boundary, error), + Err(cleanup) => Error::new(format!("{error}\n{cleanup}")), + } } } -fn same_inode(path: &Path, identity: (u64, u64)) -> bool { - fs::symlink_metadata(path).is_ok_and(|metadata| (metadata.dev(), metadata.ino()) == identity) -} - pub(crate) fn proof_artifact<'a>( ledger: &'a CandidateLedger, platform: &str, @@ -967,9 +1075,14 @@ pub fn candidate_status( expected_ledger_bytes: &[u8], ) -> Result { validate_version(&expected_ledger.version)?; + let version = VersionComponent::new(&expected_ledger.version)?; + let boundary = ReservedReleaseBoundary::new(root); + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory)? + .absolute; 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)? + || disk_ledger_bytes != ledger_bytes(root, &payload, expected_ledger)? { return Err(Error::new( "candidate ledger bytes mismatch: expected promoted ledger, actual different", @@ -985,8 +1098,18 @@ pub fn candidate_status( "candidate image policy mismatch: expected ledger build images, actual committed policy differs", )); } - let evidence_root = root.path().join("dist/rust-evidence").join(&ledger.version); - let proofs_root = evidence_root.join("proofs"); + let evidence_root = boundary + .resolve_for_read( + ReservedPath::EvidenceVersion(version.clone()), + ExpectedLeaf::Directory, + )? + .absolute; + let proofs_root = boundary + .resolve_for_read( + ReservedPath::Proofs(version.clone()), + ExpectedLeaf::Directory, + )? + .absolute; let evidence_names = directory_names(&evidence_root, "candidate evidence")?; if evidence_names != BTreeSet::from(["ledger.json".into(), "proofs".into()]) { return Err(Error::new( @@ -1004,8 +1127,12 @@ pub fn candidate_status( } let mut proof_bytes = BTreeMap::new(); for (id, _, reference) in policy.proof_policies() { - let path = proofs_root.join(format!("{id}.json")); - require_regular(&path, id)?; + let path = boundary + .resolve_for_read( + ReservedPath::Proof(version.clone(), ProofId::new(id)?), + ExpectedLeaf::RegularFile, + )? + .absolute; let image = proof_image_identity(reference); validate_proof_file( &ProofRequest { @@ -1066,19 +1193,22 @@ pub(crate) fn proof_image_identity(reference: &str) -> ImageIdentity { } pub fn recover_candidate(root: &RepoRoot, version: &str) -> Result { - if root - .path() - .join("dist/.rust-release-candidate.lock") - .symlink_metadata() - .is_ok() - { + let boundary = ReservedReleaseBoundary::new(root); + if boundary.path(ReservedPath::Lock).symlink_metadata().is_ok() { + boundary.resolve_for_read(ReservedPath::Lock, ExpectedLeaf::RegularFile)?; return Err(Error::new( "candidate recovery lock mismatch: expected absent, actual present", )); } - let before = tree_snapshot(&root.path().join("dist"))?; + let dist = boundary + .resolve_for_read(ReservedPath::Dist, ExpectedLeaf::Directory)? + .absolute; + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory)? + .absolute; + let before = tree_snapshot(&dist)?; let (ledger, bytes) = read_ledger(root, version)?; - require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + require_clean_tree(root.path(), &payload)?; let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; if commit != ledger.source.commit { return Err(Error::new(format!( @@ -1091,9 +1221,9 @@ pub fn recover_candidate(root: &RepoRoot, version: &str) -> Result { { return Err(Error::new("recovery Cargo.lock mismatch")); } - classify_release(root, &root.path().join("dist/rust"), false)?; + classify_release(root, &payload, false)?; let _ = candidate_status(root, &ledger, &bytes)?; - if tree_snapshot(&root.path().join("dist"))? != before { + if tree_snapshot(&dist)? != before { return Err(Error::new("candidate recovery mutation mismatch")); } Ok("retained-candidate-valid".into()) @@ -1333,34 +1463,28 @@ fn proof_os_release() -> Result { .ok_or_else(|| Error::new("proof OS mismatch")) } -pub(crate) fn finish_candidate_staging( +pub(crate) fn finish_candidate_staging_owned( root: &RepoRoot, - version: &str, - staging_root: &Path, + staging: &StagingLayout, 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() - ))) - } + let transaction = staging + .transaction + .clone() + .expect("production staging has a transaction"); + let cleanup = CleanupPlan::new(vec![CleanupEntry { + path: ReservedPath::StagingInvocation(transaction), + expected_type: ExpectedLeaf::Directory, + expected_identity: staging.root_identity, + ownership: OwnershipEvidence::Created, + }]) + .and_then(|plan| plan.preflight(ReservedReleaseBoundary::new(root))) + .and_then(ValidatedCleanupPlan::execute); + match (result, cleanup) { + (Ok(value), Ok(_)) => Ok(value), + (Err(error), Ok(_)) => Err(error), + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(error), Err(cleanup)) => Err(Error::new(format!("{error}\n{cleanup}"))), } } @@ -1369,13 +1493,37 @@ pub(crate) fn finish_created_candidate( owned_proofs: &[PathBuf], result: Result, ) -> Result { + let _ = owned_proofs; result.map_err(|error| { - rollback_error( - error, - &finalized.payload_root, - &finalized.evidence_root, - owned_proofs, - ) + let root_path = finalized + .payload_root + .parent() + .and_then(Path::parent) + .expect("reserved payload is below repository root"); + let root = RepoRoot::validate_path(root_path).expect("existing validated repository root"); + let version = VersionComponent::new(&finalized.ledger.version) + .expect("finalized version is validated"); + let entries = vec![ + CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: finalized.payload_identity, + ownership: OwnershipEvidence::Promoted, + }, + CleanupEntry { + path: ReservedPath::EvidenceVersion(version), + expected_type: ExpectedLeaf::Directory, + expected_identity: finalized.evidence_identity, + ownership: OwnershipEvidence::Published, + }, + ]; + match CleanupPlan::new(entries) + .and_then(|plan| plan.preflight(ReservedReleaseBoundary::new(&root))) + .and_then(ValidatedCleanupPlan::execute) + { + Ok(_) => error, + Err(cleanup) => Error::new(format!("{error}\n{cleanup}")), + } }) } @@ -1399,7 +1547,8 @@ fn create_candidate_locked( lock: &CandidateLock, ) -> Result { let version = workspace_version(root)?; - require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + let payload = ReservedReleaseBoundary::new(root).path(ReservedPath::Payload); + require_clean_tree(root.path(), &payload)?; clear_candidate_paths(root, &version)?; let staging = StagingLayout::create(root, lock)?; let result = (|| { @@ -1444,6 +1593,8 @@ fn create_candidate_locked( output: &staging.rpm_lane, processes, })?; + let baseline_executable = + reconcile_lanes(&deb, &rpm, &staging.deb_lane, &staging.rpm_lane, &version)?; let finalized = finalize_candidate(FinalizeInput { root, staging: &staging, @@ -1451,6 +1602,7 @@ fn create_candidate_locked( version: &version, deb: &deb, rpm: &rpm, + baseline_executable, cohort: &cohort, images: &images, engine, @@ -1483,7 +1635,7 @@ fn create_candidate_locked( })(); finish_created_candidate(&finalized, &owned, proof_result) })(); - finish_candidate_staging(root, &version, &staging.root, result) + finish_candidate_staging_owned(root, &staging, result) } pub fn prove_candidate( @@ -1504,8 +1656,12 @@ fn prove_candidate_locked( descriptor: &Path, processes: &ProcessEnvironment, ) -> Result { + let boundary = ReservedReleaseBoundary::new(root); + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory)? + .absolute; let (ledger, ledger_bytes) = read_ledger(root, version)?; - require_clean_tree(root.path(), &root.path().join("dist/rust"))?; + require_clean_tree(root.path(), &payload)?; 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( @@ -1520,7 +1676,7 @@ fn prove_candidate_locked( "candidate resume source mismatch: expected ledger source, actual checkout differs", )); } - classify_release(root, &root.path().join("dist/rust"), false)?; + classify_release(root, &payload, false)?; let advisory = validate_resume_advisory_identity(descriptor, processes)?; if advisory.source_id != ledger.advisory_cohort.source_id || advisory.commit != ledger.advisory_cohort.commit @@ -1558,7 +1714,7 @@ fn prove_candidate_locked( })?; } recheck_all_images(processes, engine, &images)?; - recheck_source(root, &context, &root.path().join("dist/rust"))?; + recheck_source(root, &context, &payload)?; let final_advisory = validate_resume_advisory_identity(descriptor, processes)?; if final_advisory.source_id != advisory.source_id || final_advisory.commit != advisory.commit @@ -1588,15 +1744,16 @@ fn preflight_existing_proofs( engine: ContainerEngine, processes: &ProcessEnvironment, ) -> Result<()> { - let proofs_root = root - .path() - .join("dist/rust-evidence") - .join(&ledger.version) - .join("proofs"); + let boundary = ReservedReleaseBoundary::new(root); + let version = VersionComponent::new(&ledger.version)?; + let proofs_root = boundary.path(ReservedPath::Proofs(version.clone())); if proofs_root.symlink_metadata().is_err() { return Ok(()); } - require_directory(&proofs_root, "candidate proofs")?; + boundary.resolve_for_read( + ReservedPath::Proofs(version.clone()), + ExpectedLeaf::Directory, + )?; let allowed = PROOF_SPECS .iter() .map(|spec| format!("{}.json", spec.id)) @@ -1614,8 +1771,10 @@ fn preflight_existing_proofs( } } for (id, image) in images.proof_images() { - let path = proofs_root.join(format!("{id}.json")); + let reserved = ReservedPath::Proof(version.clone(), ProofId::new(id)?); + let path = boundary.path(reserved.clone()); if path.symlink_metadata().is_ok() { + boundary.resolve_for_read(reserved, ExpectedLeaf::RegularFile)?; validate_proof_file( &ProofRequest { root, @@ -1677,12 +1836,137 @@ pub(crate) fn require_expected_commit(root: &RepoRoot, expected: &str) -> Result 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)?; + let version = VersionComponent::new(version)?; + let boundary = ReservedReleaseBoundary::new(root); + let payload_path = boundary.path(ReservedPath::Payload); + let evidence_path = boundary.path(ReservedPath::EvidenceVersion(version.clone())); + let payload_present = payload_path.symlink_metadata().is_ok(); + let evidence_present = evidence_path.symlink_metadata().is_ok(); + if !payload_present && !evidence_present { + return Ok(()); + } + let ownership_error = || { + Error::new(format!( + "existing release candidate ownership mismatch: expected complete validated solstone-linux candidate {}, actual unowned or invalid reserved paths\nrepair: run candidate recover --version {} to inspect a retained candidate; move or remove only the named dist/rust and dist/rust-evidence/{} paths after independently confirming ownership", + version.0, version.0, version.0 + )) + }; + if !payload_present || !evidence_present { + return Err(ownership_error()); + } + let payload = boundary + .resolve_for_read(ReservedPath::Payload, ExpectedLeaf::Directory) + .map_err(|_| ownership_error())?; + let evidence = boundary + .resolve_for_read( + ReservedPath::EvidenceVersion(version.clone()), + ExpectedLeaf::Directory, + ) + .map_err(|_| ownership_error())?; + let (ledger, ledger_bytes) = read_ledger(root, &version.0).map_err(|_| ownership_error())?; + validate_ledger(root, &payload.absolute, &ledger).map_err(|_| ownership_error())?; + classify_release(root, &payload.absolute, false).map_err(|_| ownership_error())?; + validate_retained_status_structure(root, &ledger, &ledger_bytes) + .map_err(|_| ownership_error())?; + let payload = boundary + .resolve_for_replace( + ReservedPath::Payload, + ExpectedLeaf::Directory, + payload.identity.expect("payload identity"), + ) + .map_err(|_| ownership_error())?; + let evidence = boundary + .resolve_for_replace( + ReservedPath::EvidenceVersion(version.clone()), + ExpectedLeaf::Directory, + evidence.identity.expect("evidence identity"), + ) + .map_err(|_| ownership_error())?; + CleanupPlan::new(vec![ + CleanupEntry { + path: ReservedPath::Payload, + expected_type: ExpectedLeaf::Directory, + expected_identity: payload.identity.expect("payload identity"), + ownership: OwnershipEvidence::RetainedCandidate, + }, + CleanupEntry { + path: ReservedPath::EvidenceVersion(version), + expected_type: ExpectedLeaf::Directory, + expected_identity: evidence.identity.expect("evidence identity"), + ownership: OwnershipEvidence::RetainedCandidate, + }, + ])? + .preflight(boundary)? + .execute()?; + Ok(()) +} + +fn validate_retained_status_structure( + root: &RepoRoot, + ledger: &CandidateLedger, + ledger_bytes: &[u8], +) -> Result<()> { + let version = VersionComponent::new(&ledger.version)?; + let boundary = ReservedReleaseBoundary::new(root); + let evidence = boundary + .resolve_for_read( + ReservedPath::EvidenceVersion(version.clone()), + ExpectedLeaf::Directory, + )? + .absolute; + let proofs = boundary + .resolve_for_read( + ReservedPath::Proofs(version.clone()), + ExpectedLeaf::Directory, + )? + .absolute; + if directory_names(&evidence, "candidate evidence")? + != BTreeSet::from(["ledger.json".into(), "proofs".into()]) + { + return Err(Error::new( + "candidate evidence inventory mismatch: expected ledger and proofs, actual different", + )); + } + let policy = ReleaseImages::from_root(root.path())?; + let allowed = PROOF_SPECS + .iter() + .map(|spec| format!("{}.json", spec.id)) + .collect::>(); + let present = directory_names(&proofs, "candidate proofs")?; + if !present.is_subset(&allowed) { + return Err(Error::new( + "candidate proof inventory mismatch: expected retained proof IDs, actual different", + )); + } + for (id, _, reference) in policy.proof_policies() { + if !present.contains(&format!("{id}.json")) { + continue; + } + let path = boundary + .resolve_for_read( + ReservedPath::Proof(version.clone(), ProofId::new(id)?), + ExpectedLeaf::RegularFile, + )? + .absolute; + validate_proof_file( + &ProofRequest { + root, + ledger, + ledger_bytes, + platform: id, + image: &proof_image_identity(reference), + engine: ContainerEngine::Podman, + processes: &ProcessEnvironment::default(), + }, + &path, + )?; + } + if present == allowed { + let status = candidate_status(root, ledger, ledger_bytes)?; + if !status.local_evidence_only || status.publication_approval { + return Err(Error::new( + "candidate status mismatch: expected local evidence without publication approval, actual different", + )); } } Ok(()) 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 index dd68e7b..3bd7a5d 100644 --- a/vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json +++ b/vendor/rust-release-candidate-ledger/rust-release-candidate-ledger.schema.json @@ -4,7 +4,7 @@ "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"], + "required": ["schema_version","product","version","source","validator","target","policy","advisory_cohort","images","tools","payload","package_members","baseline_executable","expected_proof_ids","candidate_digest"], "properties": { "schema_version": {"const": 1}, "product": {"const": "solstone-linux"}, @@ -18,6 +18,7 @@ "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"}}}}, + "baseline_executable": {"type":"object","additionalProperties":false,"required":["sha256","bytes"],"properties":{"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"},"bytes":{"type":"integer","minimum":1}}}, "expected_proof_ids": {"const":["debian-amd64","rpm-x86_64","tar-x86_64"]}, "candidate_digest": {"$ref":"#/$defs/sha256"} }, -- 2.51.2