From 3fe532e6d18d33b4c67ee55526bece0b9630567f Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 21 Jul 2026 00:41:31 -0600 Subject: [PATCH] fix(rust): harden candidate evidence and cleanup Derive lane source, lock, version, target, and profile evidence from the immutable build environment, and accept genuine rustc verbose commit hashes through the shared privacy validator. Reclaim owned proof and atomic-publish artifacts on failure, propagate lock-release errors with repair guidance, enforce canonical field-aware timestamps, and report rollback enumeration residue. Keep drift output coherent per run. Centralize proof, artifact, and native-tool authorities, and remove dead staging paths and tag-era naming. --- crates/rust-release-manifest/src/candidate.rs | 158 +++++-- .../src/candidate_tests.rs | 133 ++++-- crates/rust-release-manifest/src/lib.rs | 390 +++++++++++++----- .../rust-release-manifest/src/proof_tests.rs | 61 ++- crates/rust-release-manifest/src/tests.rs | 16 +- .../rust-release-manifest/src/transaction.rs | 346 ++++++++++------ scripts/build-release.sh | 8 +- 7 files changed, 786 insertions(+), 326 deletions(-) diff --git a/crates/rust-release-manifest/src/candidate.rs b/crates/rust-release-manifest/src/candidate.rs index 2a992cc..5bd77fa 100644 --- a/crates/rust-release-manifest/src/candidate.rs +++ b/crates/rust-release-manifest/src/candidate.rs @@ -97,9 +97,9 @@ impl ReleaseImages { pub fn proof_policies(&self) -> [(&'static str, &ProofPlatformPolicy, &str); 3] { [ - ("debian-amd64", &self.debian_amd64, &self.proof_debian), - ("rpm-x86_64", &self.rpm_x86_64, &self.proof_rpm), - ("tar-x86_64", &self.tar_x86_64, &self.proof_tar), + (PROOF_SPECS[0].id, &self.debian_amd64, &self.proof_debian), + (PROOF_SPECS[1].id, &self.rpm_x86_64, &self.proof_rpm), + (PROOF_SPECS[2].id, &self.tar_x86_64, &self.proof_tar), ] } @@ -272,7 +272,7 @@ fn validate_advisory_descriptor_identity_mode( ))); } validate_evidence_text("advisory source_id", &descriptor.source_id)?; - validate_timestamp(&descriptor.acquired_at)?; + validate_timestamp("advisory acquired_at", &descriptor.acquired_at)?; let acquired = DateTime::parse_from_rfc3339(&descriptor.acquired_at).map_err(display_error)?; let age = Utc::now().signed_duration_since(acquired); if enforce_freshness && (age.num_seconds() < 0 || age.num_seconds() > DAY_SECONDS) { @@ -559,20 +559,16 @@ impl ContainerEngine { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ImageIdentity { - pub configured_tag: String, + pub configured_reference: String, pub digest: String, } pub fn inspect_image( processes: &ProcessEnvironment, engine: ContainerEngine, - tag: &str, + reference: &str, ) -> Result { - if tag.is_empty() || tag.chars().any(char::is_control) { - return Err(Error::new( - "image tag mismatch: expected configured tag, actual invalid", - )); - } + validate_image_reference("inspect", reference)?; if engine == ContainerEngine::Docker { run_success(processes, Path::new("."), "docker", &["buildx", "version"])?; } @@ -580,7 +576,7 @@ pub fn inspect_image( processes, Path::new("."), engine.executable(), - &["image", "inspect", tag], + &["image", "inspect", reference], )?; let values: Vec = serde_json::from_slice(&output.stdout).map_err(display_error)?; if values.len() != 1 { @@ -608,7 +604,7 @@ pub fn inspect_image( ))); } Ok(ImageIdentity { - configured_tag: tag.into(), + configured_reference: reference.into(), digest: format!("sha256:{id}"), }) } @@ -750,11 +746,22 @@ pub struct LaneEmitRequest<'a> { } pub fn emit_lane_handoff(request: &LaneEmitRequest<'_>) -> Result<()> { + emit_lane_handoff_in(request, Path::new(".")) +} + +pub(crate) fn emit_lane_handoff_in( + request: &LaneEmitRequest<'_>, + context_root: &Path, +) -> Result<()> { require_image_digest(request.image_digest)?; - require_commit(request.source_commit, "lane source commit")?; + let binding: ContextBinding = strict_json_file(&context_root.join(CONTEXT_BINDING_NAME))?; + let archive = fs::read(context_root.join(CONTEXT_ARCHIVE_NAME)).map_err(display_error)?; + let observed_commit = git_archive_commit(&archive)?; + let observed_archive_sha256 = digest(&archive); + require_commit(&observed_commit, "lane source commit")?; for (label, value) in [ - ("source archive digest", request.source_archive_sha256), - ("Cargo.lock digest", request.expected_cargo_lock_sha256), + ("source archive digest", observed_archive_sha256.as_str()), + ("Cargo.lock digest", binding.cargo_lock_sha256.as_str()), ] { if !is_sha256(value) { return Err(Error::new(format!( @@ -762,22 +769,71 @@ pub fn emit_lane_handoff(request: &LaneEmitRequest<'_>) -> Result<()> { ))); } } - validate_version(request.version)?; - let actual_lock = digest(&fs::read("Cargo.lock").map_err(display_error)?); - if actual_lock != request.expected_cargo_lock_sha256 { + if binding.source_commit != observed_commit + || binding.source_archive_sha256 != observed_archive_sha256 + || observed_commit != request.source_commit + || observed_archive_sha256 != request.source_archive_sha256 + || binding.cargo_lock_sha256 != request.expected_cargo_lock_sha256 + { + return Err(Error::new( + "lane immutable context binding mismatch: expected exported context authority, actual carried arguments differ", + )); + } + let workspace: toml::Value = toml::from_str( + &fs::read_to_string(context_root.join("Cargo.toml")).map_err(display_error)?, + ) + .map_err(display_error)?; + let actual_version = workspace["workspace"]["package"]["version"] + .as_str() + .ok_or_else(|| Error::new("lane version mismatch: expected workspace version"))? + .to_owned(); + validate_version(&actual_version)?; + if actual_version != request.version { + return Err(Error::new(format!( + "lane version mismatch: expected {actual_version}, actual {}", + request.version + ))); + } + let actual_lock = digest(&fs::read(context_root.join("Cargo.lock")).map_err(display_error)?); + if actual_lock != binding.cargo_lock_sha256 { return Err(Error::new(format!( "Cargo.lock digest mismatch: expected {}, actual {actual_lock}", - request.expected_cargo_lock_sha256 + binding.cargo_lock_sha256 ))); } - let mut features = request.features.clone(); - features.sort(); - if features != request.features { + if !request.features.is_empty() { return Err(Error::new( - "lane features mismatch: expected sorted features, actual unsorted", + "lane features mismatch: expected build command's empty feature set, actual nonempty", )); } + let features = Vec::new(); let rustc_verbose = command_evidence("rustc", &["--version", "--verbose"])?; + let actual_target = rustc_verbose + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .ok_or_else(|| Error::new("lane target mismatch: expected rustc host"))? + .to_owned(); + if actual_target != request.target { + return Err(Error::new(format!( + "lane target mismatch: expected {actual_target}, actual {}", + request.target + ))); + } + let executable_path = request.baseline_executable.to_string_lossy(); + let actual_profile = if executable_path.contains("/release/") + || executable_path.starts_with("target/release/") + { + "release" + } else { + return Err(Error::new( + "lane profile mismatch: expected release build output, actual other", + )); + }; + if actual_profile != request.profile { + return Err(Error::new( + "lane profile mismatch: expected observed release output", + )); + } let cargo = command_evidence("cargo", &["--version"])?; let packaging_tool = match request.lane { Lane::Deb => command_evidence("cargo", &["deb", "--version"]), @@ -824,12 +880,15 @@ pub fn emit_lane_handoff(request: &LaneEmitRequest<'_>) -> Result<()> { let evidence = LaneEvidence { invocation_id: request.invocation_id.into(), lane: request.lane, - source_commit: request.source_commit.into(), - source_archive_sha256: request.source_archive_sha256.into(), + // The invocation nonce and base-image digest are carried authorities: the + // nonce has no in-container source, while the digest is the same value + // consumed by FROM and cannot be introspected from a build container. + source_commit: observed_commit, + source_archive_sha256: observed_archive_sha256, cargo_lock_sha256: actual_lock, - version: request.version.into(), - target: request.target.into(), - profile: request.profile.into(), + version: actual_version, + target: actual_target, + profile: actual_profile.into(), features, rustc_verbose, cargo, @@ -1071,7 +1130,7 @@ fn validate_lane_inventory(request: &LaneRequest<'_>, evidence_name: &str) -> Re ); } let mut expected = BTreeSet::from([evidence_name.to_owned()]); - expected.extend(expected_artifact_names(request.lane, request.version)); + expected.extend(expected_artifact_names(request.lane, request.version)?); if actual != expected { return Err(Error::new(format!( "lane output inventory mismatch: expected {expected:?}, actual {actual:?}" @@ -1080,13 +1139,16 @@ fn validate_lane_inventory(request: &LaneRequest<'_>, evidence_name: &str) -> Re Ok(()) } -fn expected_artifact_names(lane: Lane, version: &str) -> [String; 2] { - let tar = format!("solstone-linux-{version}-linux-x86_64.tar.gz"); - let native = match lane { - Lane::Deb => format!("solstone-linux_{version}-1_amd64.deb"), - Lane::Rpm => format!("solstone-linux-{version}-1.x86_64.rpm"), - }; - [tar, native] +fn expected_artifact_names(lane: Lane, version: &str) -> Result<[String; 2]> { + let tar = artifact_name("tar", version)?; + let native = artifact_name( + match lane { + Lane::Deb => "deb", + Lane::Rpm => "rpm", + }, + version, + )?; + Ok([tar, native]) } pub(crate) fn validate_lane_evidence( @@ -1177,6 +1239,26 @@ pub(crate) fn validate_lane_evidence( } fn validate_lane_native_tools(evidence: &LaneEvidence, request: &LaneRequest<'_>) -> Result<()> { + let actual = serde_json::from_value::>( + serde_json::to_value(&evidence.native_tools).map_err(display_error)?, + ) + .map_err(display_error)?; + let expected = TOOL_SPECS + .iter() + .filter(|spec| { + spec.source == ToolSource::BothLanes + || matches!( + (request.lane, spec.source), + (Lane::Deb, ToolSource::Ubuntu) | (Lane::Rpm, ToolSource::Fedora) + ) + }) + .map(|spec| spec.key) + .collect::>(); + if actual.keys().map(String::as_str).collect::>() != expected { + return Err(Error::new( + "lane native tool inventory mismatch: expected typed lane authority, actual different", + )); + } match (&evidence.native_tools, request.lane) { (LaneNativeTools::Ubuntu(tools), Lane::Deb) => { for (actual, expected, key) in [ @@ -1349,7 +1431,7 @@ pub fn recheck_images( expected: [&ImageIdentity; 2], ) -> Result<()> { for identity in expected { - let actual = inspect_image(processes, engine, &identity.configured_tag)?; + let actual = inspect_image(processes, engine, &identity.configured_reference)?; if actual.digest != identity.digest { return Err(Error::new(format!( "image identity mismatch: expected {}, actual {}", diff --git a/crates/rust-release-manifest/src/candidate_tests.rs b/crates/rust-release-manifest/src/candidate_tests.rs index 49478b4..d63fd14 100644 --- a/crates/rust-release-manifest/src/candidate_tests.rs +++ b/crates/rust-release-manifest/src/candidate_tests.rs @@ -197,7 +197,40 @@ fn fixture_binds_real_repository_authorities() { assert_eq!(repo.commit.len(), 40); assert!(is_sha256(&repo.cargo_lock_sha256)); assert_eq!(repo.cargo_deny_version, CARGO_DENY_VERSION); - assert_eq!(repo.exceptions, EXCEPTIONS); + assert_eq!(repo.exceptions, TEST_EXCEPTIONS); +} + +#[test] +fn lane_rejects_carried_source_that_disagrees_with_exported_context() { + let repo = fixture(); + let temp = tempfile::tempdir().unwrap(); + let context_root = temp.path().join("context"); + fs::create_dir(&context_root).unwrap(); + let context = export_immutable_context(&repo.root, &context_root).unwrap(); + let error = emit_lane_handoff_in( + &LaneEmitRequest { + lane: Lane::Deb, + invocation_id: "d", + source_commit: &"e".repeat(40), + source_archive_sha256: &context.archive_sha256, + expected_cargo_lock_sha256: &context.cargo_lock_sha256, + version: "1.0.0", + target: TARGET_TRIPLE, + profile: "release", + features: Vec::new(), + image_digest: &format!("sha256:{}", "f".repeat(64)), + baseline_executable: Path::new("target/release/solstone-linux"), + artifacts: Vec::new(), + output: Path::new("unused"), + }, + &context_root, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("immutable context binding mismatch") + ); } #[test] @@ -226,7 +259,19 @@ fn exclusive_lock_loser_mutates_nothing() { } #[test] -fn staging_and_emptying_paths_are_owner_scoped() { +fn explicit_lock_release_reports_replacement_and_preserves_foreign_file() { + let repo = fixture(); + let lock = CandidateLock::acquire(&repo.root).unwrap(); + let path = lock.path().to_owned(); + fs::remove_file(&path).unwrap(); + fs::write(&path, b"foreign").unwrap(); + let error = lock.release().unwrap_err(); + assert!(error.to_string().contains("repair:")); + assert_eq!(fs::read(path).unwrap(), b"foreign"); +} + +#[test] +fn staging_paths_are_owner_scoped() { let repo = fixture(); let lock = CandidateLock::acquire(&repo.root).unwrap(); let staging = StagingLayout::create(&repo.root, &lock).unwrap(); @@ -243,15 +288,9 @@ fn staging_and_emptying_paths_are_owner_scoped() { &staging.rpm_lane, &staging.advisory_db, &staging.payload, - &staging.proofs, ] { assert!(path.is_dir()); } - let id = staging.root.file_name().unwrap().to_str().unwrap(); - let allowlist = owner_emptying_allowlist(&repo.root, "1.0.0", id).unwrap(); - assert_eq!(allowlist.len(), 4); - assert!(!allowlist.contains(&repo.root.path().join("dist"))); - assert!(allowlist.contains(&staging.root)); } #[test] @@ -714,13 +753,23 @@ pub(super) fn lane_tools(lane: Lane, image_digest: &str) -> LaneNativeTools { #[test] fn image_inspection_and_recheck_are_immutable_and_local() { let script = format!( - "#!/bin/sh\ncase \"$*\" in '--version') printf '%s' 'podman version 5.8.3' ;; 'image inspect ubuntu:tool') printf '%s' '{}' ;; 'image inspect fedora:tool') printf '%s' '{}' ;; *) exit 97;; esac\n", + "#!/bin/sh\ncase \"$*\" in '--version') printf '%s' 'podman version 5.8.3' ;; 'image inspect sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') printf '%s' '{}' ;; 'image inspect sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb') printf '%s' '{}' ;; *) exit 97;; esac\n", image_json('a'), image_json('b') ); let (_bin, processes) = process_bin("#!/bin/sh\nexit 97\n", Some(&script)); - let ubuntu = inspect_image(&processes, ContainerEngine::Podman, "ubuntu:tool").unwrap(); - let fedora = inspect_image(&processes, ContainerEngine::Podman, "fedora:tool").unwrap(); + let ubuntu = inspect_image( + &processes, + ContainerEngine::Podman, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + let fedora = inspect_image( + &processes, + ContainerEngine::Podman, + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + .unwrap(); assert_eq!(ubuntu.digest, format!("sha256:{}", "a".repeat(64))); assert_eq!( observe_container_engine(&processes, ContainerEngine::Podman).unwrap(), @@ -739,7 +788,14 @@ fn image_inspection_and_recheck_are_immutable_and_local() { .is_err() ); let (_bin, missing) = process_bin("#!/bin/sh\nexit 97\n", Some("#!/bin/sh\nexit 44\n")); - assert!(inspect_image(&missing, ContainerEngine::Podman, "missing:tool").is_err()); + assert!( + inspect_image( + &missing, + ContainerEngine::Podman, + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ) + .is_err() + ); } pub(super) fn lane_fixture( @@ -768,7 +824,7 @@ pub(super) fn lane_fixture( target: TARGET_TRIPLE.into(), profile: "release".into(), features: vec![], - rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), + rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: 0123456789abcdef0123456789abcdef01234567\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), cargo: "cargo 1.97.1 (abcdef012 2026-06-30)".into(), baseline_executable_sha256: "d".repeat(64), image_digest: image.into(), @@ -1267,11 +1323,13 @@ fn lane_build_argv_is_offline_no_pull_and_uses_exported_context() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; lane_handoff( @@ -1281,7 +1339,7 @@ fn lane_build_argv_is_offline_no_pull_and_uses_exported_context() { "0123456789abcdef0123456789abcdef", &ubuntu.digest, b"same tar", - "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0\n", + "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: 0123456789abcdef0123456789abcdef01234567\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0\n", ); fs::write( repo.root.path().join("packaging/Containerfile"), @@ -1391,11 +1449,13 @@ fn lane_build_rejects_extra_output_and_unsafe_rustc_evidence() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let rustc = match case { @@ -1447,11 +1507,13 @@ fn lane_build_rejects_malformed_image_digests_before_subprocess() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest, }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let tripwire = staging.root.join("container-tripwire"); @@ -1509,11 +1571,13 @@ fn production_handoff_rejects_missing_duplicate_unknown_and_byte_drift() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let evidence = lane_handoff( @@ -1580,11 +1644,13 @@ fn production_handoff_rejects_stale_swapped_and_crosswired_documents() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let evidence = lane_handoff( @@ -1626,11 +1692,13 @@ fn production_handoff_rejects_stale_swapped_and_crosswired_documents() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let rustc = @@ -1706,11 +1774,13 @@ fn production_handoff_rejects_every_native_identity_mutation() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let output = match lane { @@ -1782,11 +1852,13 @@ fn lane_evidence_rejects_stale_swapped_crosswired_and_tar_mismatch() { let staging = StagingLayout::create(&repo.root, &lock).unwrap(); let context = export_immutable_context(&repo.root, &staging.context).unwrap(); let ubuntu = ImageIdentity { - configured_tag: "ubuntu:tool".into(), + configured_reference: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), digest: format!("sha256:{}", "a".repeat(64)), }; let fedora = ImageIdentity { - configured_tag: "fedora:tool".into(), + configured_reference: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), digest: format!("sha256:{}", "b".repeat(64)), }; let invocation = "0123456789abcdef0123456789abcdef"; @@ -1875,3 +1947,4 @@ fn manifest_tool_map_is_derived_from_two_lanes_and_host_identity() { assert_eq!(tools["ubuntu_image_digest"], "a".repeat(64)); assert_eq!(tools["fedora_image_digest"], "b".repeat(64)); } +pub const TEST_EXCEPTIONS: [&str; 2] = ["RUSTSEC-2026-0194", "RUSTSEC-2026-0195"]; diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index 1f6fd49..1a388f4 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -13,7 +13,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Cursor, Read}; -use std::net::IpAddr; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt, symlink}; use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -31,6 +30,8 @@ pub const CHECKSUM_NAME: &str = "SHA256SUMS"; pub const PRODUCT: &str = "solstone-linux"; pub const TARGET_TRIPLE: &str = "x86_64-unknown-linux-gnu"; pub const CARGO_DENY_VERSION: &str = "0.20.2"; +pub(crate) const CONTEXT_BINDING_NAME: &str = ".release-context.json"; +pub(crate) const CONTEXT_ARCHIVE_NAME: &str = ".release-context.tar"; pub const MANIFEST_OK_MESSAGE: &str = "Named manifest and artifacts verified; this is NOT candidate-readiness classification."; pub const RELEASE_DIR_OK_MESSAGE: &str = @@ -63,29 +64,126 @@ const EXPECTED_LAYOUT: [&str; 9] = [ "crates/solstone-linux/Cargo.toml", "crates/rust-release-manifest/Cargo.toml", ]; -const TOOL_KEYS: [&str; 18] = [ - "cargo_deb", - "cargo_generate_rpm", - "container_engine", - "dpkg_deb", - "fedora_image_digest", - "fedora_os", - "manifest_validator", - "rpm", - "signing_mode", - "ubuntu_cargo", - "ubuntu_compiler", - "ubuntu_glibc", - "ubuntu_gzip", - "ubuntu_image_digest", - "ubuntu_linker", - "ubuntu_os", - "ubuntu_rustc", - "ubuntu_tar", +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSource { + Host, + Ubuntu, + Fedora, + BothLanes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ToolSpec { + pub key: &'static str, + pub source: ToolSource, +} + +pub(crate) const TOOL_SPECS: [ToolSpec; 18] = [ + ToolSpec { + key: "cargo_deb", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "cargo_generate_rpm", + source: ToolSource::Fedora, + }, + ToolSpec { + key: "container_engine", + source: ToolSource::Host, + }, + ToolSpec { + key: "dpkg_deb", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "fedora_image_digest", + source: ToolSource::Fedora, + }, + ToolSpec { + key: "fedora_os", + source: ToolSource::Fedora, + }, + ToolSpec { + key: "manifest_validator", + source: ToolSource::Host, + }, + ToolSpec { + key: "rpm", + source: ToolSource::Fedora, + }, + ToolSpec { + key: "signing_mode", + source: ToolSource::BothLanes, + }, + ToolSpec { + key: "ubuntu_cargo", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_compiler", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_glibc", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_gzip", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_image_digest", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_linker", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_os", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_rustc", + source: ToolSource::Ubuntu, + }, + ToolSpec { + key: "ubuntu_tar", + source: ToolSource::Ubuntu, + }, ]; -#[cfg(test)] -const EXCEPTIONS: [&str; 2] = ["RUSTSEC-2026-0194", "RUSTSEC-2026-0195"]; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProofSpec { + pub id: &'static str, + pub artifact_kind: &'static str, + pub architecture: &'static str, +} + +pub const PROOF_SPECS: [ProofSpec; 3] = [ + ProofSpec { + id: "debian-amd64", + artifact_kind: "deb", + architecture: "amd64", + }, + ProofSpec { + id: "rpm-x86_64", + artifact_kind: "rpm", + architecture: "x86_64", + }, + ProofSpec { + id: "tar-x86_64", + artifact_kind: "tar", + architecture: "x86_64", + }, +]; + +pub fn proof_spec(id: &str) -> Result { + PROOF_SPECS + .into_iter() + .find(|spec| spec.id == id) + .ok_or_else(|| Error::new("proof platform mismatch: expected known proof ID")) +} #[derive(Debug)] pub struct Error(String); @@ -157,6 +255,7 @@ impl RepoRoot { pub struct CandidateLock { path: PathBuf, file: File, + released: bool, } impl CandidateLock { @@ -171,19 +270,47 @@ impl CandidateLock { .open(&path) .map_err(|error| { Error::new(format!( - "release candidate lock mismatch: expected exclusive owner, actual {error}" + "release candidate lock mismatch: expected exclusive owner, actual {error}\nrepair: confirm no candidate process is running, then remove only dist/.rust-release-candidate.lock" )) })?; - Ok(Self { path, file }) + Ok(Self { + path, + file, + released: false, + }) } pub fn path(&self) -> &Path { &self.path } + + pub fn release(mut self) -> Result<()> { + self.remove_owned()?; + self.released = true; + Ok(()) + } + + fn remove_owned(&self) -> Result<()> { + let owned = self.file.metadata().map_err(display_error)?; + let metadata = fs::symlink_metadata(&self.path).map_err(display_error)?; + if metadata.dev() != owned.dev() || metadata.ino() != owned.ino() { + return Err(Error::new( + "release candidate lock cleanup mismatch: expected owned lock, actual replaced\nrepair: inspect dist/.rust-release-candidate.lock and remove it only after confirming no candidate process is running", + )); + } + fs::remove_file(&self.path).map_err(|error| { + Error::new(format!( + "release candidate lock cleanup mismatch: expected owned lock removed, actual {error}\nrepair: remove only dist/.rust-release-candidate.lock after confirming no candidate process is running" + )) + }) + } } impl Drop for CandidateLock { fn drop(&mut self) { + if self.released { + return; + } let owned = self.file.metadata(); let same_file = fs::symlink_metadata(&self.path).is_ok_and(|metadata| { owned @@ -204,8 +331,6 @@ pub struct StagingLayout { pub rpm_lane: PathBuf, pub advisory_db: PathBuf, pub payload: PathBuf, - pub proofs: PathBuf, - pub ledger_temp: PathBuf, } impl StagingLayout { @@ -234,8 +359,6 @@ impl StagingLayout { rpm_lane: staging.join("lane-rpm"), advisory_db: staging.join("advisory-db"), payload: staging.join("payload"), - proofs: staging.join("proofs"), - ledger_temp: staging.join("ledger.json.tmp"), root: staging.clone(), }; let setup = (|| { @@ -245,7 +368,6 @@ impl StagingLayout { &layout.rpm_lane, &layout.advisory_db, &layout.payload, - &layout.proofs, ] { fs::create_dir_all(directory).map_err(display_error)?; } @@ -272,29 +394,12 @@ pub struct ImmutableContext { pub path: PathBuf, } -pub fn owner_emptying_allowlist( - root: &RepoRoot, - version: &str, - transaction_id: &str, -) -> Result> { - validate_version(version)?; - if transaction_id.len() != 32 - || !transaction_id - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - { - return Err(Error::new( - "transaction ID mismatch: expected 32 lowercase hexadecimal characters, actual invalid", - )); - } - let dist = root.path().join("dist"); - Ok(BTreeSet::from([ - dist.join("rust"), - dist.join("rust-evidence").join(version), - dist.join("rust-drift"), - dist.join(".rust-release-candidate-staging") - .join(transaction_id), - ])) +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ContextBinding { + pub source_commit: String, + pub source_archive_sha256: String, + pub cargo_lock_sha256: String, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -617,7 +722,10 @@ pub fn bundle_digest_input( "candidate digest mismatch: expected sha256, actual invalid", )); } - let expected = BTreeSet::from(["debian-amd64", "rpm-x86_64", "tar-x86_64"]); + let expected = PROOF_SPECS + .iter() + .map(|spec| spec.id) + .collect::>(); let actual = proofs.keys().map(String::as_str).collect::>(); if actual != expected { return Err(Error::new(format!( @@ -786,9 +894,9 @@ pub fn validate_candidate_proof(value: &Value, expected: &ProofBindings) -> Resu let proof_time = object["proof_time"] .as_str() .ok_or_else(|| Error::new("proof proof_time mismatch"))?; - validate_timestamp(proof_time)?; - validate_timestamp(&expected.policy_checked_at)?; - validate_timestamp(&expected.validation_time)?; + validate_timestamp("proof_time", proof_time)?; + validate_timestamp("policy checked_at", &expected.policy_checked_at)?; + validate_timestamp("proof validation time", &expected.validation_time)?; let proof_time = DateTime::parse_from_rfc3339(proof_time).map_err(display_error)?; let checked_at = DateTime::parse_from_rfc3339(&expected.policy_checked_at).map_err(display_error)?; @@ -839,6 +947,11 @@ pub fn export_immutable_context(root: &RepoRoot, destination: &Path) -> Result Result Result Result { + if archive.len() < 1024 || archive[156] != b'g' { + return Err(Error::new( + "git archive identity mismatch: expected global PAX header", + )); + } + let size = std::str::from_utf8(&archive[124..136]) + .map_err(display_error)? + .trim_matches(['\0', ' ']); + let size = usize::from_str_radix(size, 8).map_err(display_error)?; + let body = archive + .get(512..512 + size) + .ok_or_else(|| Error::new("git archive identity mismatch: expected complete PAX header"))?; + let body = std::str::from_utf8(body).map_err(display_error)?; + let commits = body + .lines() + .filter_map(|line| line.split_once(" comment=").map(|(_, value)| value)) + .filter(|value| is_git_commit(value)) + .collect::>(); + if commits.len() != 1 { + return Err(Error::new( + "git archive identity mismatch: expected one commit comment", + )); + } + Ok(commits[0].to_owned()) +} + fn validate_evidence(root: &RepoRoot, evidence: &Evidence) -> Result<()> { if evidence.schema_version != SCHEMA_VERSION || evidence.product != PRODUCT @@ -871,7 +1019,10 @@ fn validate_evidence(root: &RepoRoot, evidence: &Evidence) -> Result<()> { validate_evidence_text("rust.rustc_verbose", &evidence.rust.rustc_verbose)?; validate_evidence_text("rust.cargo_version", &evidence.rust.cargo_version)?; validate_native_tools(root, &evidence.native_tools)?; - validate_timestamp(&evidence.dependency_policy.advisory_checked_at) + validate_timestamp( + "advisory checked_at", + &evidence.dependency_policy.advisory_checked_at, + ) } fn validate_manifest_policy(root: &RepoRoot, manifest: &Manifest) -> Result<()> { @@ -879,7 +1030,10 @@ fn validate_manifest_policy(root: &RepoRoot, manifest: &Manifest) -> Result<()> validate_evidence_text("rust.rustc_verbose", &manifest.rust.rustc_verbose)?; validate_evidence_text("rust.cargo_version", &manifest.rust.cargo_version)?; validate_native_tools(root, &manifest.native_tools)?; - validate_timestamp(&manifest.dependency_policy.advisory_checked_at)?; + validate_timestamp( + "advisory checked_at", + &manifest.dependency_policy.advisory_checked_at, + )?; validate_artifact_set(&manifest.artifacts)?; for artifact in &manifest.artifacts { artifact_kind(&artifact.path, Some(&manifest.version))?; @@ -888,6 +1042,10 @@ fn validate_manifest_policy(root: &RepoRoot, manifest: &Manifest) -> Result<()> } fn validate_evidence_text(field: &str, value: &str) -> Result<()> { + validate_privacy(field, value, true) +} + +fn validate_privacy(field: &str, value: &str, allow_multiline: bool) -> Result<()> { let lower = value.to_ascii_lowercase(); let forbidden = [ "token", @@ -897,6 +1055,11 @@ fn validate_evidence_text(field: &str, value: &str) -> Result<()> { "localhost", ".local", ".internal", + "staging", + "sandbox", + "preview", + " dev ", + " test ", "socket", "pipe:", "ipc:", @@ -921,18 +1084,24 @@ fn validate_evidence_text(field: &str, value: &str) -> Result<()> { }; ipv4 || ipv6 }); + let compiler_commit = value.lines().find_map(|line| { + line.strip_prefix("commit-hash: ") + .filter(|hash| is_git_commit(hash)) + }); let opaque_blob = tokens.iter().any(|token| { - token.len() >= 20 - && *token != TARGET_TRIPLE - && token.chars().all(|character| { - character.is_ascii_alphanumeric() - || matches!(character, '+' | '/' | '=' | '_' | '-') - }) + (token.len() >= 12 && token.chars().all(|character| character.is_ascii_digit())) + || (token.len() >= 20 + && *token != TARGET_TRIPLE + && Some(*token) != compiler_commit + && token.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '+' | '/' | '=' | '_' | '-') + })) }); let bad = value.is_empty() - || value - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\t')) + || value.chars().any(|character| { + character.is_control() && !(allow_multiline && matches!(character, '\n' | '\t')) + }) || value.contains(['$', '%', '\\', '/', '@']) || value.contains("://") || forbidden.iter().any(|word| lower.contains(word)) @@ -946,19 +1115,39 @@ fn validate_evidence_text(field: &str, value: &str) -> Result<()> { Ok(()) } -fn validate_timestamp(value: &str) -> Result<()> { - DateTime::parse_from_rfc3339(value).map_err(|_| Error::new("advisory time mismatch"))?; - if !value.ends_with('Z') { - return Err(Error::new( - "advisory time mismatch: expected canonical UTC Z suffix", - )); +fn validate_timestamp(field: &str, value: &str) -> Result<()> { + let bytes = value.as_bytes(); + let canonical_shape = bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z' + && bytes.iter().enumerate().all(|(index, byte)| { + matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit() + }); + if !canonical_shape { + return Err(Error::new(format!( + "{field} mismatch: expected canonical UTC seconds with Z suffix" + ))); + } + let parsed = DateTime::parse_from_rfc3339(value) + .map_err(|_| Error::new(format!("{field} mismatch: expected canonical UTC time")))?; + if parsed.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) != value { + return Err(Error::new(format!( + "{field} mismatch: expected canonical UTC seconds with Z suffix" + ))); } Ok(()) } fn validate_native_tools(root: &RepoRoot, tools: &BTreeMap) -> Result<()> { let actual = tools.keys().map(String::as_str).collect::>(); - let expected = TOOL_KEYS.into_iter().collect::>(); + let expected = TOOL_SPECS + .iter() + .map(|spec| spec.key) + .collect::>(); if actual != expected { return Err(Error::new(format!( "native tools mismatch: expected 18 keys, actual {}", @@ -978,7 +1167,7 @@ fn validate_native_tools(root: &RepoRoot, tools: &BTreeMap) -> R return Err(Error::new(format!("native tool {key} mismatch"))); } } - for key in TOOL_KEYS { + for key in TOOL_SPECS.iter().map(|spec| spec.key) { if !matches!( key, "cargo_deb" @@ -1006,24 +1195,8 @@ fn exact_tool(tools: &BTreeMap, key: &str, expected: &str) -> Re } pub(crate) fn validate_identity(key: &str, value: &str) -> Result<()> { - let lower = value.to_ascii_lowercase(); - let forbidden = [ - "token", - "secret", - "password", - "bearer", - "localhost", - ".local", - ".internal", - "staging", - "sandbox", - "preview", - " dev ", - " test ", - "socket", - "pipe:", - "ipc:", - ]; + validate_privacy(&format!("native tool {key}"), value, false) + .map_err(|_| Error::new(format!("native tool {key} identity mismatch")))?; let approved_prefixes: &[&str] = match key { "container_engine" => &["podman ", "docker "], "ubuntu_os" => &["Ubuntu "], @@ -1037,31 +1210,12 @@ pub(crate) fn validate_identity(key: &str, value: &str) -> Result<()> { "rpm" => &["RPM ", "rpm "], _ => &[], }; - let tokens = value.split_whitespace().collect::>(); - let private_identifier = tokens.iter().any(|token| { - token.parse::().is_ok() - || (token.len() >= 32 - && token.contains('-') - && token - .chars() - .all(|character| character.is_ascii_hexdigit() || character == '-')) - || (token.len() >= 12 && token.chars().all(|character| character.is_ascii_digit())) - || (token.len() >= 20 - && token.chars().all(|character| { - character.is_ascii_alphanumeric() - || matches!(character, '+' | '/' | '=' | '_' | '-') - })) - }); let bad = !approved_prefixes .iter() .any(|prefix| value.starts_with(prefix)) || value.trim() != value || value.contains(" ") || value.chars().any(|character| character.is_control()) - || value.contains(['$', '%', '@', '/', '\\']) - || value.contains("://") - || forbidden.iter().any(|word| lower.contains(word)) - || private_identifier || !value.chars().any(|character| character.is_ascii_digit()) || value.split_whitespace().count() > 6; if bad { @@ -1131,6 +1285,18 @@ fn artifact_kind(name: &str, expected_version: Option<&str>) -> Result<&'static Ok(kind) } +pub(crate) fn artifact_name(kind: &str, version: &str) -> Result { + validate_version(version)?; + match kind { + "tar" => Ok(format!("solstone-linux-{version}-linux-x86_64.tar.gz")), + "deb" => Ok(format!("solstone-linux_{version}-1_amd64.deb")), + "rpm" => Ok(format!("solstone-linux-{version}-1.x86_64.rpm")), + _ => Err(Error::new( + "artifact kind mismatch: expected tar, deb, or rpm", + )), + } +} + fn artifact_paths(root: &Path, version: &str) -> Result> { require_directory(root, "release root")?; let mut paths = Vec::new(); diff --git a/crates/rust-release-manifest/src/proof_tests.rs b/crates/rust-release-manifest/src/proof_tests.rs index 252d94e..5f72494 100644 --- a/crates/rust-release-manifest/src/proof_tests.rs +++ b/crates/rust-release-manifest/src/proof_tests.rs @@ -2,11 +2,41 @@ // Copyright (c) 2026 sol pbc use super::*; -use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; const CANDIDATE_VECTOR: &str = "27e7dd62da4e0022b755f669dd00118a57715aaf088ff7f2a6c322951238494e"; const BUNDLE_VECTOR: &str = "cd214a005b2186a7eb25e9fd756561fb9c6e47e02004047c1cd5132106580a3e"; +fn proof_ids() -> [&'static str; 3] { + [PROOF_SPECS[0].id, PROOF_SPECS[1].id, PROOF_SPECS[2].id] +} + +#[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"); + 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( + Error::new("primary"), + &attempt, + (attempt_metadata.dev(), attempt_metadata.ino()), + &published, + true, + Some((published_metadata.dev(), published_metadata.ino())), + ); + assert_eq!(error.to_string(), "primary"); + assert!(!attempt.exists()); + assert!(!published.exists()); + assert_eq!(fs::read(foreign).unwrap(), b"foreign"); +} + #[test] fn candidate_schemas_are_digest_and_identity_pinned() { verify_candidate_schemas().unwrap(); @@ -373,7 +403,7 @@ fn fixed_payload_and_ledger_serialization_are_reproducible() { tools: BTreeMap::new(), payload, package_members: Vec::new(), - expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + expected_proof_ids: proof_ids().map(str::to_owned).to_vec(), candidate_digest: candidate, }; let first = canonical_json(&serde_json::to_value(&ledger).unwrap()).unwrap(); @@ -502,14 +532,14 @@ fn retained_fixture_from(repo: crate::candidate_tests::TestRepo) -> RetainedFixt tools, payload: artifacts.clone(), package_members: members, - expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + expected_proof_ids: proof_ids().map(str::to_owned).to_vec(), candidate_digest: candidate_digest(&artifacts).unwrap(), }; let ledger_bytes = ledger_bytes(&repo.root, &payload, &ledger).unwrap(); let evidence = repo.root.path().join("dist/rust-evidence/1.0.0"); fs::create_dir_all(evidence.join("proofs")).unwrap(); atomic_write_0644(&evidence.join("ledger.json"), &ledger_bytes).unwrap(); - for id in PROOF_IDS { + for id in proof_ids() { write_valid_proof(&repo.root, &ledger, &ledger_bytes, id); } RetainedFixture { @@ -598,7 +628,10 @@ fn bundle_digest_callsite_invariant_requires_validated_exact_inventory() { let fixture = retained_fixture(); let status = candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).unwrap(); - assert_eq!(status.proofs.keys().cloned().collect::>(), PROOF_IDS); + assert_eq!( + status.proofs.keys().cloned().collect::>(), + proof_ids() + ); assert_eq!( status.candidate_digest, candidate_digest(&fixture.ledger.payload).unwrap() @@ -694,7 +727,7 @@ fn status_and_recovery_reject_every_retained_binding_mutation() { "network", "isolation", ]; - for id in PROOF_IDS { + for id in proof_ids() { let path = fixture .repo .root @@ -781,7 +814,7 @@ fn status_and_recovery_reject_every_retained_binding_mutation() { .into_iter() .map(str::to_owned) .collect::>(); - ledger_pointers.extend(TOOL_KEYS.map(|key| format!("/tools/{key}"))); + ledger_pointers.extend(TOOL_SPECS.iter().map(|spec| format!("/tools/{}", spec.key))); for index in 0..fixture.ledger.payload.len() { for field in ["path", "bytes", "sha256"] { ledger_pointers.push(format!("/payload/{index}/{field}")); @@ -1027,7 +1060,7 @@ fn prove_candidate_rejects_source_change_during_first_proof_run() { .path() .join("dist/rust-evidence/1.0.0/proofs"); let templates = tempfile::tempdir().unwrap(); - for id in PROOF_IDS { + for id in proof_ids() { fs::copy( proofs.join(format!("{id}.json")), templates.path().join(format!("{id}.json")), @@ -1081,7 +1114,7 @@ fn prove_resume_preserves_payload_ledger_and_first_proof_without_rebuild() { .path() .join("dist/rust-evidence/1.0.0/proofs"); let templates = tempfile::tempdir().unwrap(); - for id in PROOF_IDS { + for id in proof_ids() { fs::copy( proofs.join(format!("{id}.json")), templates.path().join(format!("{id}.json")), @@ -1161,7 +1194,7 @@ fn create_readiness_ledger_replacement_rolls_back_promoted_candidate() { &finalized.ledger, &finalized.ledger_bytes, ); - let owned = PROOF_IDS.map(|id| { + let owned = proof_ids().map(|id| { finalized .evidence_root .join("proofs") @@ -1265,7 +1298,7 @@ fn docker_identity_is_normalized_and_retained_validation_is_provider_neutral() { &fixture.ledger_bytes, ) .unwrap(); - for id in PROOF_IDS { + for id in proof_ids() { let path = fixture .repo .root @@ -1416,7 +1449,7 @@ fn docker_create_templates(root: &RepoRoot, directory: &Path) { target: TARGET_TRIPLE.into(), profile: "release".into(), features: vec![], - rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: abcdef012\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), + rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: 0123456789abcdef0123456789abcdef01234567\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), cargo: "cargo 1.97.1 (abcdef012 2026-06-30)".into(), baseline_executable_sha256: "d".repeat(64), image_digest: "@IMAGE@".into(), @@ -1436,7 +1469,7 @@ fn docker_create_templates(root: &RepoRoot, directory: &Path) { }; fs::write(directory.join(name), serde_json::to_vec(&evidence).unwrap()).unwrap(); } - for id in PROOF_IDS { + for id in proof_ids() { let platform = policy.proof_policy(id).unwrap(); let artifact_name = match id { "debian-amd64" => "solstone-linux_1.0.0-1_amd64.deb", @@ -1596,7 +1629,7 @@ exit 94 } let processes = ProcessEnvironment::with_path(stubs.path().as_os_str()); for (marker, class) in [(&fail_producer, "producer"), (&fail_validator, "validator")] { - for id in PROOF_IDS { + for id in proof_ids() { fs::write(marker, id).unwrap(); let failed = crate::candidate_tests::fixture(); let descriptor_dir = tempfile::tempdir().unwrap(); diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 1e024b9..ae1609a 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -85,7 +85,7 @@ pub(super) fn evidence() -> Evidence { source_dirty: false, cargo_lock_sha256: digest(&fs::read(root.join("Cargo.lock")).unwrap()), rust: RustEvidence { - rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), + rustc_verbose: "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: 0123456789abcdef0123456789abcdef01234567\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0".into(), cargo_version: "cargo 1.97.1 (c980f4866 2026-06-30)".into(), }, target: TargetEvidence::Compiled { @@ -99,10 +99,20 @@ pub(super) fn evidence() -> Evidence { deterministic_gate: "pass".into(), advisory_checked_at: "2026-07-20T12:34:56Z".into(), }, - active_exceptions: EXCEPTIONS.iter().map(|value| (*value).into()).collect(), + active_exceptions: crate::candidate_tests::TEST_EXCEPTIONS + .iter() + .map(|value| (*value).into()) + .collect(), } } +#[test] +fn real_rustc_verbose_shape_is_privacy_safe() { + let banner = "rustc 1.97.1 (abcdef012 2026-06-30)\nbinary: rustc\ncommit-hash: 0123456789abcdef0123456789abcdef01234567\ncommit-date: 2026-06-30\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 18.1.0"; + validate_evidence_text("rust.rustc_verbose", banner).unwrap(); + assert!(validate_evidence_text("other evidence", &"a".repeat(40)).is_err()); +} + fn tarball(root: &Path, version: &str) -> PathBuf { let name = format!("solstone-linux-{version}-linux-x86_64.tar.gz"); let path = root.join(name); @@ -416,7 +426,7 @@ fn rust_release_manifest_conformance() { bad["dependency_policy"]["advisory_checked_at"] = Value::String("not-a-date".into()); assert!(validate_manifest_bytes(serde_json::to_string(&bad).unwrap().as_bytes()).is_err()); - for key in TOOL_KEYS { + for key in TOOL_SPECS.iter().map(|spec| spec.key) { let mut value: Value = serde_json::from_str(&first).unwrap(); value["native_tools"].as_object_mut().unwrap().remove(key); assert!( diff --git a/crates/rust-release-manifest/src/transaction.rs b/crates/rust-release-manifest/src/transaction.rs index 92889da..ab96e95 100644 --- a/crates/rust-release-manifest/src/transaction.rs +++ b/crates/rust-release-manifest/src/transaction.rs @@ -7,8 +7,6 @@ use serde::{Deserialize, Serialize}; use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -pub const PROOF_IDS: [&str; 3] = ["debian-amd64", "rpm-x86_64", "tar-x86_64"]; - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct CandidateLedger { @@ -148,7 +146,7 @@ pub fn construct_ledger(input: LedgerInput<'_>) -> Result { tools: input.tools, payload, package_members: members, - expected_proof_ids: PROOF_IDS.map(str::to_owned).to_vec(), + expected_proof_ids: PROOF_SPECS.iter().map(|spec| spec.id.to_owned()).collect(), candidate_digest: candidate, }; validate_ledger(input.root, input.payload_root, &ledger)?; @@ -182,8 +180,8 @@ pub fn validate_ledger( return Err(Error::new(format!("ledger schema mismatch: {error}"))); } validate_version(&ledger.version)?; - validate_timestamp(&ledger.policy.checked_at)?; - validate_timestamp(&ledger.advisory_cohort.acquired_at)?; + validate_timestamp("policy checked_at", &ledger.policy.checked_at)?; + validate_timestamp("advisory acquired_at", &ledger.advisory_cohort.acquired_at)?; validate_evidence_text("ledger advisory source", &ledger.advisory_cohort.source_id)?; validate_native_tools(root, &ledger.tools)?; if ledger.validator.version != env!("CARGO_PKG_VERSION") { @@ -231,7 +229,10 @@ pub fn validate_ledger( "ledger payload digest mismatch: expected promoted bytes, actual different", )); } - if ledger.package_members.len() != 3 || ledger.expected_proof_ids != PROOF_IDS { + let expected_proofs = PROOF_SPECS.iter().map(|spec| spec.id).collect::>(); + if ledger.package_members.len() != PROOF_SPECS.len() + || ledger.expected_proof_ids != expected_proofs + { return Err(Error::new("ledger evidence inventory mismatch")); } let mut members = package_members(payload_root, &ledger.version)?; @@ -288,11 +289,26 @@ pub fn atomic_write_0644(path: &Path, bytes: &[u8]) -> Result<()> { fs::set_permissions(&temp, fs::Permissions::from_mode(0o644)).map_err(display_error)?; fs::rename(&temp, path).map_err(display_error) })(); + let owned = file.metadata().map_err(display_error)?; drop(file); finish_atomic_publish(&temp, publish)?; - File::open(parent) + let synced = File::open(parent) .and_then(|file| file.sync_all()) - .map_err(display_error) + .map_err(display_error); + if let Err(error) = synced { + let same_file = fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.dev() == owned.dev() && metadata.ino() == owned.ino()); + 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() + )) + })?; + } + return Err(error); + } + Ok(()) } pub(crate) fn finish_atomic_publish(temp: &Path, publish: Result<()>) -> Result<()> { @@ -559,17 +575,27 @@ pub fn rollback_error( } } let proofs_root = evidence_root.join("proofs"); - if proofs_root.is_dir() - && fs::read_dir(&proofs_root).is_ok_and(|mut entries| entries.next().is_none()) - && fs::remove_dir(&proofs_root).is_err() - { - residues.push(proofs_root); + if 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() - && fs::read_dir(evidence_root).is_ok_and(|mut entries| entries.next().is_none()) - && fs::remove_dir(evidence_root).is_err() - { - residues.push(evidence_root.to_owned()); + if 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 @@ -630,11 +656,7 @@ pub struct ProofRequest<'a> { } pub fn produce_or_retain_proof(request: &ProofRequest<'_>) -> Result { - if !PROOF_IDS.contains(&request.platform) { - return Err(Error::new( - "proof platform mismatch: expected known proof ID, actual unknown", - )); - } + proof_spec(request.platform)?; let evidence_root = request .root .path() @@ -653,97 +675,141 @@ pub fn produce_or_retain_proof(request: &ProofRequest<'_>) -> Result { transaction_id()? )); fs::create_dir(&attempt).map_err(display_error)?; - let artifact = proof_artifact(request.ledger, request.platform)?; - let artifact_path = request.root.path().join("dist/rust").join(&artifact.path); - require_regular(&artifact_path, "proof artifact")?; - let executable = std::env::current_exe().map_err(display_error)?; - let output_arg = format!("type=bind,src={},dst=/evidence", attempt.display()); - let artifact_arg = format!( - "type=bind,src={},dst=/input/{},ro", - artifact_path.display(), - artifact.path - ); - let runner_arg = format!( - "type=bind,src={},dst=/proof-runner,ro", - executable.display() - ); - let mut args = vec![ - "run".into(), - "--rm".into(), - "--pull=never".into(), - "--network=none".into(), - "--mount".into(), - output_arg, - "--mount".into(), - artifact_arg, - "--mount".into(), - runner_arg, - ]; - if request.platform == "tar-x86_64" { - args.extend([ + let attempt_metadata = fs::symlink_metadata(&attempt).map_err(display_error)?; + let mut published = false; + 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); + require_regular(&artifact_path, "proof artifact")?; + let executable = std::env::current_exe().map_err(display_error)?; + let output_arg = format!("type=bind,src={},dst=/evidence", attempt.display()); + let artifact_arg = format!( + "type=bind,src={},dst=/input/{},ro", + artifact_path.display(), + artifact.path + ); + let runner_arg = format!( + "type=bind,src={},dst=/proof-runner,ro", + executable.display() + ); + let mut args = vec![ + "run".into(), + "--rm".into(), + "--pull=never".into(), + "--network=none".into(), + "--mount".into(), + output_arg, "--mount".into(), - format!( - "type=bind,src={},dst=/input/install.sh,ro", - request.root.path().join("scripts/install.sh").display() - ), + artifact_arg, + "--mount".into(), + runner_arg, + ]; + if request.platform == "tar-x86_64" { + args.extend([ + "--mount".into(), + format!( + "type=bind,src={},dst=/input/install.sh,ro", + request.root.path().join("scripts/install.sh").display() + ), + ]); + } + args.extend([ + request.image.digest.clone(), + "/proof-runner".into(), + "proof-handoff".into(), + "--platform".into(), + request.platform.into(), + "--artifact".into(), + format!("/input/{}", artifact.path), + "--output".into(), + "/evidence/proof.json".into(), + "--candidate-digest".into(), + request.ledger.candidate_digest.clone(), + "--ledger-sha256".into(), + digest(request.ledger_bytes), + "--source-commit".into(), + request.ledger.source.commit.clone(), + "--cargo-lock-sha256".into(), + request.ledger.source.cargo_lock_sha256.clone(), + "--proof-image-digest".into(), + request.image.digest.clone(), + "--version".into(), + request.ledger.version.clone(), ]); + run_success_owned( + request.processes, + request.root.path(), + request.engine.executable(), + &args, + )?; + let produced = attempt.join("proof.json"); + validate_proof_file(request, &produced)?; + let bytes = fs::read(&produced).map_err(display_error)?; + fs::remove_dir_all(&attempt).map_err(display_error)?; + atomic_write_0644(&final_path, &bytes)?; + published = true; + let metadata = fs::symlink_metadata(&final_path).map_err(display_error)?; + published_identity = Some((metadata.dev(), metadata.ino())); + validate_proof_file(request, &final_path)?; + Ok(final_path.clone()) + })(); + result.map_err(|error| { + cleanup_proof_attempt( + error, + &attempt, + (attempt_metadata.dev(), attempt_metadata.ino()), + &final_path, + published, + published_identity, + ) + }) +} + +pub(crate) fn cleanup_proof_attempt( + error: Error, + attempt: &Path, + attempt_identity: (u64, u64), + published: &Path, + was_published: bool, + published_identity: Option<(u64, u64)>, +) -> Error { + let mut residue = Vec::new(); + if same_inode(attempt, attempt_identity) && fs::remove_dir_all(attempt).is_err() { + residue.push(attempt.to_owned()); } - args.extend([ - request.image.digest.clone(), - "/proof-runner".into(), - "proof-handoff".into(), - "--platform".into(), - request.platform.into(), - "--artifact".into(), - format!("/input/{}", artifact.path), - "--output".into(), - "/evidence/proof.json".into(), - "--candidate-digest".into(), - request.ledger.candidate_digest.clone(), - "--ledger-sha256".into(), - digest(request.ledger_bytes), - "--source-commit".into(), - request.ledger.source.commit.clone(), - "--cargo-lock-sha256".into(), - request.ledger.source.cargo_lock_sha256.clone(), - "--proof-image-digest".into(), - request.image.digest.clone(), - "--version".into(), - request.ledger.version.clone(), - ]); - let run = run_success_owned( - request.processes, - request.root.path(), - request.engine.executable(), - &args, - ); - if let Err(error) = run { - let _ = fs::remove_dir_all(&attempt); - return Err(error); + if let Some(identity) = published_identity + && same_inode(published, identity) + && fs::remove_file(published).is_err() + { + residue.push(published.to_owned()); } - let produced = attempt.join("proof.json"); - let validation = validate_proof_file(request, &produced); - if let Err(error) = validation { - let _ = fs::remove_dir_all(&attempt); - return Err(error); + if was_published && published_identity.is_none() && published.symlink_metadata().is_ok() { + residue.push(published.to_owned()); + } + if residue.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(", ") + )) } - let bytes = fs::read(&produced).map_err(display_error)?; - fs::remove_dir_all(&attempt).map_err(display_error)?; - atomic_write_0644(&final_path, &bytes)?; - validate_proof_file(request, &final_path)?; - Ok(final_path) +} + +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, ) -> Result<&'a Artifact> { - let kind = match platform { - "debian-amd64" => "deb", - "rpm-x86_64" => "rpm", - "tar-x86_64" => "tar", - _ => return Err(Error::new("proof platform mismatch")), - }; + let kind = proof_spec(platform)?.artifact_kind; ledger .payload .iter() @@ -814,23 +880,19 @@ pub fn validate_proof_file(request: &ProofRequest<'_>, path: &Path) -> Result>(); if directory_names(&proofs_root, "candidate proofs")? != expected_proof_names { return Err(Error::new( @@ -924,10 +981,10 @@ pub fn candidate_status( )); } let mut proof_bytes = BTreeMap::new(); - for (id, reference) in proof_references { + for (id, _, reference) in policy.proof_policies() { let path = proofs_root.join(format!("{id}.json")); require_regular(&path, id)?; - let image = proof_image_identity(&reference); + let image = proof_image_identity(reference); validate_proof_file( &ProofRequest { root, @@ -981,7 +1038,7 @@ fn directory_names(path: &Path, label: &str) -> Result> { pub(crate) fn proof_image_identity(reference: &str) -> ImageIdentity { let digest = format!("sha256:{}", reference.rsplit_once("sha256:").unwrap().1); ImageIdentity { - configured_tag: reference.into(), + configured_reference: reference.into(), digest, } } @@ -1308,10 +1365,21 @@ pub fn create_candidate( ) -> Result { require_expected_commit(root, expected_commit)?; let lock = CandidateLock::acquire(root)?; + let result = create_candidate_locked(root, expected_commit, descriptor, processes, &lock); + finish_candidate_lock(lock, result) +} + +fn create_candidate_locked( + root: &RepoRoot, + expected_commit: &str, + descriptor: &Path, + processes: &ProcessEnvironment, + lock: &CandidateLock, +) -> Result { let version = workspace_version(root)?; require_clean_tree(root.path(), &root.path().join("dist/rust"))?; clear_candidate_paths(root, &version)?; - let staging = StagingLayout::create(root, &lock)?; + let staging = StagingLayout::create(root, lock)?; let result = (|| { let context = export_immutable_context(root, &staging.context)?; if context.commit != expected_commit { @@ -1403,7 +1471,17 @@ pub fn prove_candidate( processes: &ProcessEnvironment, ) -> Result { validate_version(version)?; - let _lock = CandidateLock::acquire(root)?; + let lock = CandidateLock::acquire(root)?; + let result = prove_candidate_locked(root, version, descriptor, processes); + finish_candidate_lock(lock, result) +} + +fn prove_candidate_locked( + root: &RepoRoot, + version: &str, + descriptor: &Path, + processes: &ProcessEnvironment, +) -> Result { let (ledger, ledger_bytes) = read_ledger(root, version)?; require_clean_tree(root.path(), &root.path().join("dist/rust"))?; let commit = command(root.path(), &["git", "rev-parse", "HEAD"])?; @@ -1471,6 +1549,15 @@ pub fn prove_candidate( candidate_status(root, &ledger, &ledger_bytes) } +fn finish_candidate_lock(lock: CandidateLock, result: Result) -> Result { + match (result, lock.release()) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(release)) => Err(release), + (Err(primary), Err(release)) => Err(Error::new(format!("{primary}\n{release}"))), + } +} + fn preflight_existing_proofs( root: &RepoRoot, ledger: &CandidateLedger, @@ -1488,7 +1575,10 @@ fn preflight_existing_proofs( return Ok(()); } require_directory(&proofs_root, "candidate proofs")?; - let allowed = BTreeSet::from(PROOF_IDS.map(|id| format!("{id}.json"))); + let allowed = PROOF_SPECS + .iter() + .map(|spec| format!("{}.json", spec.id)) + .collect::>(); for entry in fs::read_dir(&proofs_root).map_err(display_error)? { let name = entry .map_err(display_error)? @@ -1523,9 +1613,9 @@ fn preflight_existing_proofs( fn proof_images(images: &ResolvedImages) -> [(&'static str, &ImageIdentity); 3] { [ - ("debian-amd64", &images.proof_debian), - ("rpm-x86_64", &images.proof_rpm), - ("tar-x86_64", &images.proof_tar), + (PROOF_SPECS[0].id, &images.proof_debian), + (PROOF_SPECS[1].id, &images.proof_rpm), + (PROOF_SPECS[2].id, &images.proof_tar), ] } fn recheck_all_images( @@ -1540,7 +1630,7 @@ fn recheck_all_images( &images.proof_rpm, &images.proof_tar, ] { - let actual = inspect_image(processes, engine, &image.configured_tag)?; + let actual = inspect_image(processes, engine, &image.configured_reference)?; if actual.digest != image.digest { return Err(Error::new(format!( "image identity mismatch: expected {}, actual {}", diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 67fe024..daba3b5 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -95,7 +95,6 @@ trap 'rm -rf "$OUTPUT_TMP"' EXIT "$REPO_ROOT" OUTPUT_DIR=dist/rust-drift -mkdir -p "$OUTPUT_DIR" shopt -s nullglob OUTPUTS=("$OUTPUT_TMP"/* "$OUTPUT_TMP"/.[!.]*) shopt -u nullglob @@ -104,6 +103,13 @@ if [[ ${#OUTPUTS[@]} -ne 3 ]]; then echo "repair: inspect the selected local build image and packaging/Containerfile" >&2 exit 1 fi +if [[ -L "$OUTPUT_DIR" ]]; then + echo "error: drift output mismatch: expected owned directory or absent, actual symlink" >&2 + echo "repair: remove the dist/rust-drift symlink after preserving any needed data" >&2 + exit 1 +fi +rm -rf -- "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" for SOURCE in "${OUTPUTS[@]}"; do install -m 0644 "$SOURCE" "$OUTPUT_DIR/${SOURCE##*/}" done -- 2.51.2