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