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"} },