lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit c2ff9cfb9207edadfcc12c81e14dc295dbe3b2ac
parent 6c27a358c36fc8027d2919a2325ce91a5d0d1791
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 13:32:59 +0000

release: verify public package archives

- package every public crate in governed publish order
- match package lists to archives and extracted files
- check normalized dependency manifests and supported profiles
- record clean candidate provenance before aggregate coverage

Diffstat:
Mbuild/nix/common.nix | 4++--
Mcrates/outbox/contracts/migration_authority_v1.manifest.json | 12++++++------
Mcrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 2+-
Mcrates/outbox/contracts/phase1_publication_v1.manifest.json | 8++++----
Mcrates/outbox/contracts/phase1_publication_v1.manifest.sha256 | 2+-
Mtools/xtask/src/contract.rs | 37++++++++++++++++---------------------
Atools/xtask/src/contract/release_package.rs | 967+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 5++++-
8 files changed, 1001 insertions(+), 36 deletions(-)

diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -119,7 +119,7 @@ let fuzzCargoDeps = pkgs.rustPlatform.importCargoLock { lockFile = ../../fuzz/Cargo.lock; }; - releaseRuntimeInputs = coverageRuntimeInputs; + releaseRuntimeInputs = coverageRuntimeInputs ++ [ pkgs.gnutar ]; coreContractCrates = [ "xtask" "radroots_blossom" @@ -429,6 +429,7 @@ let cargo check -q cargo test -q -p xtask cargo run -q -p xtask -- contract validate + cargo run -q -p xtask -- release preflight required_file="$(mktemp)" trap 'rm -f "$required_file"' EXIT @@ -457,7 +458,6 @@ let --out target/coverage/coverage-refresh.tsv \ --status-out target/coverage/coverage-refresh-status.tsv - cargo run -q -p xtask -- release preflight echo "release preflight complete" ''; coverageReportCommand = '' diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -305,28 +305,28 @@ }, { "file": { - "byte_length": 495349, + "byte_length": 494829, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/contract.rs", - "sha256": "fc6c2e794ef5c787ed134f38982b1c18518cde30fb21643669428fa05a3eccd3" + "sha256": "487946ba039f8d72b5ed1d372c42d49337d009d161aa31495fc7b232da4c03e8" }, "role": "contract_dispatch" }, { "file": { - "byte_length": 24752, + "byte_length": 24835, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/main.rs", - "sha256": "4defaf823f7ab58322b59a568eb07d68e09c66830a9624b043529e29a3032daf" + "sha256": "0c75609e8a3612ff17ce8b99d6fafba84994865e1863535ac818d9a54678bf90" }, "role": "xtask_dispatch" }, { "file": { - "byte_length": 21097, + "byte_length": 21116, "hash_algorithm": "sha256_bytes_v1", "path": "build/nix/common.nix", - "sha256": "1018f1b95b91ad18f0664b2d44f0694a67e00253a3e93f7b9da4b3b75caa3a86" + "sha256": "37ee0871648c4ac379935e7c3030384c297d7ca715999e5b4347daaf31674cb7" }, "role": "nix_feature_executor" }, diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256 @@ -1 +1 @@ -168348114a174c2f745ae388182d604832dee24efa2c24781d03a3a10d64462a +360da1cc5c0924b721c10e5e91e803b8b65430b32c3af0f3bd02ee1f9d9d3267 diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.json b/crates/outbox/contracts/phase1_publication_v1.manifest.json @@ -112,17 +112,17 @@ }, { "file": { - "byte_length": 495349, + "byte_length": 494829, "path": "tools/xtask/src/contract.rs", - "sha256": "fc6c2e794ef5c787ed134f38982b1c18518cde30fb21643669428fa05a3eccd3" + "sha256": "487946ba039f8d72b5ed1d372c42d49337d009d161aa31495fc7b232da4c03e8" }, "role": "contract_dispatch" }, { "file": { - "byte_length": 24752, + "byte_length": 24835, "path": "tools/xtask/src/main.rs", - "sha256": "4defaf823f7ab58322b59a568eb07d68e09c66830a9624b043529e29a3032daf" + "sha256": "0c75609e8a3612ff17ce8b99d6fafba84994865e1863535ac818d9a54678bf90" }, "role": "xtask_dispatch" }, diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 b/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 @@ -1 +1 @@ -e8fea684b1e1f37d99e0ffbc974ec4fb1b069f5ab9b50b5c8bdafa624c1c7246 +25055c1dcb12ff7a9d0a83eccae00ddb64549be81006c6962fc7b57cc2e1c954 diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -23,6 +23,8 @@ mod phase1_publication_artifact; #[allow(dead_code)] mod raw_source_rebuild; mod registry_v7; +#[cfg_attr(test, allow(dead_code))] +mod release_package; mod release_provenance; mod source_maintenance; @@ -52,6 +54,8 @@ pub(crate) use phase1_publication_media_readiness::{ pub(crate) use registry_v7::{ validate_event_contract_registry_v7_inventory, write_event_contract_registry_v7_inventory, }; +#[cfg(not(test))] +pub(crate) use release_package::validate_release_packages; pub(crate) use release_provenance::{ validate_release_provenance_schema, write_release_provenance, write_release_provenance_schema, }; @@ -331,6 +335,7 @@ const KNOWLEDGE_BETA_CONTRACT_IDS: [&str; 3] = [ const EVENT_BOUNDARY_MATRIX_ENV: &str = "RADROOTS_EVENT_BOUNDARY_MATRIX"; const COVERAGE_REQUIRED_THRESHOLD: f64 = 100.0; const COVERAGE_REQUIRED_THRESHOLD_LABEL: &str = "100/100/100/100"; +#[cfg_attr(not(test), allow(dead_code))] const COVERAGE_REPORT_EPSILON: f64 = 0.000_001; const DTO_TOOLING_DEPENDENCIES: [&str; 4] = [ "dto_bindgen", @@ -8041,9 +8046,11 @@ struct CoverageRefreshRow { func: f64, branch: Option<f64>, region: f64, + #[cfg_attr(not(test), allow(dead_code))] report_path: PathBuf, } +#[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Deserialize)] struct CoverageGateReportForValidation { scope: String, @@ -8052,6 +8059,7 @@ struct CoverageGateReportForValidation { result: CoverageGateReportResultForValidation, } +#[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Deserialize)] struct CoverageGateReportThresholdsForValidation { executable_lines: f64, @@ -8061,6 +8069,7 @@ struct CoverageGateReportThresholdsForValidation { branches_required: bool, } +#[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Deserialize)] struct CoverageGateReportMeasuredForValidation { executable_lines_percent: f64, @@ -8070,6 +8079,7 @@ struct CoverageGateReportMeasuredForValidation { summary_regions_percent: f64, } +#[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Deserialize)] struct CoverageGateReportResultForValidation { pass: bool, @@ -8196,6 +8206,7 @@ fn validate_required_coverage_summary( Ok(()) } +#[cfg_attr(not(test), allow(dead_code))] fn read_coverage_gate_report( path: &Path, crate_name: &str, @@ -8219,10 +8230,12 @@ fn read_coverage_gate_report( }) } +#[cfg_attr(not(test), allow(dead_code))] fn coverage_percent_matches(left: f64, right: f64) -> bool { (left - right).abs() <= COVERAGE_REPORT_EPSILON } +#[cfg_attr(not(test), allow(dead_code))] fn coverage_branch_percent_matches(left: Option<f64>, right: Option<f64>) -> bool { match (left, right) { (Some(left), Some(right)) => coverage_percent_matches(left, right), @@ -8231,6 +8244,7 @@ fn coverage_branch_percent_matches(left: Option<f64>, right: Option<f64>) -> boo } } +#[cfg_attr(not(test), allow(dead_code))] fn coverage_gate_report_thresholds_match( report: &CoverageGateReportThresholdsForValidation, thresholds: CoverageThresholds, @@ -8242,6 +8256,7 @@ fn coverage_gate_report_thresholds_match( && report.branches_required == thresholds.require_branches } +#[cfg_attr(not(test), allow(dead_code))] fn validate_coverage_gate_report_for_row( crate_name: &str, row: &CoverageRefreshRow, @@ -8291,6 +8306,7 @@ fn validate_coverage_gate_report_for_row( Ok(()) } +#[cfg_attr(not(test), allow(dead_code))] fn validate_required_coverage_summary_with_policy( workspace_root: &Path, required_crates: &BTreeSet<String>, @@ -8669,18 +8685,10 @@ fn validate_release_preflight_with_override_and_profile( bundle.version.contract.version.as_str(), release_policy_override, )?; - let policy = - load_coverage_policy(&bundle.root).expect("validated contract includes coverage policy"); let publish_crates = collect_unique_set(&release.public_crates(), "classification.public") .expect("validated contract enforces unique public crates"); - let required_crate_list = policy - .required_crates() - .expect("validated contract includes required crates"); - let required_crates = collect_unique_set(&required_crate_list, "required.crates") - .expect("validated contract enforces unique required.crates"); validate_publishable_dto_tooling_sources(workspace_root, &publish_crates)?; validate_publish_package_metadata(workspace_root, &publish_crates)?; - validate_required_coverage_summary_with_policy(workspace_root, &required_crates, &policy)?; Ok(()) } @@ -13258,19 +13266,6 @@ edition = "2024" .expect_err("publish metadata validation"); assert!(publish_metadata_err.contains("must define a non-empty package.description")); let _ = fs::remove_dir_all(&publish_metadata); - - let missing_coverage_row = create_synthetic_workspace("preflight_missing_coverage_row"); - write_file( - &missing_coverage_row - .join("target") - .join("coverage") - .join("coverage-refresh.tsv"), - "crate\tstatus\texec\tfunc\tbranch\tregion\treport\n", - ); - let missing_coverage_row_err = validate_generic_release_preflight(&missing_coverage_row) - .expect_err("required coverage refresh row missing"); - assert!(missing_coverage_row_err.contains("missing from coverage-refresh.tsv")); - let _ = fs::remove_dir_all(&missing_coverage_row); } #[test] diff --git a/tools/xtask/src/contract/release_package.rs b/tools/xtask/src/contract/release_package.rs @@ -0,0 +1,967 @@ +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; + +const POLICY_RELATIVE: &str = "contracts/releases/publish_policy.toml"; +const PROFILES_RELATIVE: &str = "contracts/coverage-profiles.toml"; +const POLICY_SCHEMA_VERSION: u32 = 1; +const CLOSURE_DIRECTORY: &str = "release-package-closure"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleasePolicy { + schema: PolicySchema, + release: ReleaseVersion, + classification: ReleaseClassification, + publish_order: PublishOrder, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicySchema { + version: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseVersion { + version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseClassification { + public: Vec<String>, + internal: Vec<String>, + deferred: Vec<String>, + retired: Vec<String>, + yank_only: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PublishOrder { + crates: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FeatureProfile { + no_default_features: bool, + features: Vec<String>, + test_threads: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FeatureProfilesFile { + profiles: FeatureProfiles, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FeatureProfiles { + default: FeatureProfile, + #[serde(default)] + crates: BTreeMap<String, FeatureProfile>, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec<CargoMetadataPackage>, + workspace_members: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadataPackage { + id: String, + name: String, + manifest_path: PathBuf, +} + +struct PackageWorkspaceEntry { + name: String, + source_directory: PathBuf, +} + +pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), String> { + ensure_clean_git_worktree(workspace_root)?; + let policy = load_toml::<ReleasePolicy>(&workspace_root.join(POLICY_RELATIVE))?; + let public = validate_policy(&policy)?; + let profiles = load_toml::<FeatureProfilesFile>(&workspace_root.join(PROFILES_RELATIVE))?; + let selected_profiles = select_profiles(&policy, &profiles.profiles)?; + let workspace_packages = load_workspace_packages(workspace_root)?; + + let cargo_target_root = cargo_target_root(workspace_root)?; + let closure_root = cargo_target_root.join(CLOSURE_DIRECTORY); + reset_owned_closure_directory(&cargo_target_root, &closure_root)?; + let archive_root = closure_root.join("archives"); + let extracted_root = closure_root.join("extracted"); + let list_root = closure_root.join("package-lists"); + let check_root = closure_root.join("archive-workspace"); + for directory in [&archive_root, &extracted_root, &list_root, &check_root] { + fs::create_dir_all(directory) + .map_err(|error| format!("create {}: {error}", directory.display()))?; + } + + for package in &policy.publish_order.crates { + let package_list = cargo_package_list(workspace_root, package)?; + fs::write( + list_root.join(format!("{package}.txt")), + format!( + "{}\n", + package_list.iter().cloned().collect::<Vec<_>>().join("\n") + ), + ) + .map_err(|error| format!("write package list for {package}: {error}"))?; + + run_cargo( + workspace_root, + &["package", "--locked", "--no-verify", "-p", package], + &format!("package {package}"), + )?; + let filename = format!("{package}-{}.crate", policy.release.version); + let cargo_archive = cargo_target_root.join("package").join(&filename); + require_regular_file(&cargo_archive, &format!("Cargo archive for {package}"))?; + let governed_archive = archive_root.join(&filename); + fs::copy(&cargo_archive, &governed_archive).map_err(|error| { + format!( + "copy package archive {} to {}: {error}", + cargo_archive.display(), + governed_archive.display() + ) + })?; + + let package_extract_root = extracted_root.join(package); + extract_and_validate_archive( + &governed_archive, + &package_extract_root, + package, + &policy.release.version, + &package_list, + )?; + validate_normalized_manifest( + &package_extract_root.join("Cargo.toml"), + package, + &policy.release.version, + &public, + )?; + } + + write_archive_workspace(&check_root, &extracted_root, &workspace_packages, &public)?; + run_cargo( + &check_root, + &["generate-lockfile"], + "lock archive workspace", + )?; + for (package, profile) in selected_profiles { + let mut args = vec!["check", "--locked", "-p", package.as_str(), "--lib"]; + if profile.no_default_features { + args.push("--no-default-features"); + } + let feature_argument; + if !profile.features.is_empty() { + feature_argument = profile.features.join(","); + args.push("--features"); + args.push(&feature_argument); + } + run_cargo( + &check_root, + &args, + &format!("check extracted package {package}"), + )?; + } + + super::write_release_provenance( + workspace_root, + &archive_root, + &closure_root.join("release-provenance.json"), + )?; + eprintln!( + "release package closure verified under {}", + closure_root.display() + ); + Ok(()) +} + +fn load_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { + let raw = + fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn validate_policy(policy: &ReleasePolicy) -> Result<BTreeSet<String>, String> { + if policy.schema.version != POLICY_SCHEMA_VERSION { + return Err(format!( + "release package policy schema.version must be {POLICY_SCHEMA_VERSION}" + )); + } + if policy.release.version.trim().is_empty() { + return Err("release package policy version must not be empty".to_owned()); + } + let classes = [ + ("classification.public", &policy.classification.public), + ("classification.internal", &policy.classification.internal), + ("classification.deferred", &policy.classification.deferred), + ("classification.retired", &policy.classification.retired), + ("classification.yank_only", &policy.classification.yank_only), + ]; + let mut classified = BTreeSet::new(); + for (label, values) in classes { + let unique = unique_nonempty(values, label)?; + for value in unique { + if !classified.insert(value.clone()) { + return Err(format!( + "release package policy classifies {value} more than once" + )); + } + } + } + let public = unique_nonempty(&policy.classification.public, "classification.public")?; + let ordered = unique_nonempty(&policy.publish_order.crates, "publish_order.crates")?; + if ordered != public { + return Err( + "release package publish order must contain every public package exactly once" + .to_owned(), + ); + } + Ok(public) +} + +fn unique_nonempty(values: &[String], label: &str) -> Result<BTreeSet<String>, String> { + let mut unique = BTreeSet::new(); + for value in values { + if value.trim().is_empty() { + return Err(format!("{label} contains an empty package name")); + } + if !unique.insert(value.clone()) { + return Err(format!("{label} contains duplicate package {value}")); + } + } + Ok(unique) +} + +fn select_profiles( + policy: &ReleasePolicy, + profiles: &FeatureProfiles, +) -> Result<Vec<(String, FeatureProfile)>, String> { + validate_profile("profiles.default", &profiles.default)?; + let classified = policy + .classification + .public + .iter() + .chain(&policy.classification.internal) + .chain(&policy.classification.deferred) + .chain(&policy.classification.retired) + .chain(&policy.classification.yank_only) + .cloned() + .collect::<BTreeSet<_>>(); + for (package, profile) in &profiles.crates { + if !classified.contains(package) { + return Err(format!( + "release feature profile references unknown package {package}" + )); + } + validate_profile(&format!("profiles.crates.{package}"), profile)?; + } + Ok(policy + .publish_order + .crates + .iter() + .map(|package| { + ( + package.clone(), + profiles + .crates + .get(package) + .unwrap_or(&profiles.default) + .clone(), + ) + }) + .collect()) +} + +fn validate_profile(label: &str, profile: &FeatureProfile) -> Result<(), String> { + if profile.test_threads == 0 { + return Err(format!("{label}.test_threads must be positive")); + } + unique_nonempty(&profile.features, &format!("{label}.features"))?; + Ok(()) +} + +fn cargo_target_root(workspace_root: &Path) -> Result<PathBuf, String> { + let raw = env::var_os("CARGO_TARGET_DIR") + .ok_or_else(|| "release package preflight requires CARGO_TARGET_DIR".to_owned())?; + let target = PathBuf::from(raw); + if !target.is_absolute() { + return Err("release package preflight requires an absolute CARGO_TARGET_DIR".to_owned()); + } + fs::create_dir_all(&target) + .map_err(|error| format!("create CARGO_TARGET_DIR {}: {error}", target.display()))?; + let target = fs::canonicalize(&target).map_err(|error| { + format!( + "canonicalize CARGO_TARGET_DIR {}: {error}", + target.display() + ) + })?; + let workspace = fs::canonicalize(workspace_root) + .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?; + if target.starts_with(&workspace) || workspace.starts_with(&target) { + return Err( + "release package preflight CARGO_TARGET_DIR must be outside the source worktree" + .to_owned(), + ); + } + Ok(target) +} + +fn reset_owned_closure_directory(target_root: &Path, closure_root: &Path) -> Result<(), String> { + if closure_root.parent() != Some(target_root) + || closure_root.file_name().and_then(|name| name.to_str()) != Some(CLOSURE_DIRECTORY) + { + return Err("refusing to reset an unowned release package directory".to_owned()); + } + match fs::symlink_metadata(closure_root) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Err(format!( + "release package closure path {} must be a real directory", + closure_root.display() + )), + Ok(_) => fs::remove_dir_all(closure_root) + .map_err(|error| format!("reset {}: {error}", closure_root.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("inspect {}: {error}", closure_root.display())), + } +} + +fn cargo_package_list(workspace_root: &Path, package: &str) -> Result<BTreeSet<String>, String> { + let output = cargo_output( + workspace_root, + &["package", "--locked", "--list", "-p", package], + &format!("list package {package}"), + )?; + let stdout = String::from_utf8(output.stdout) + .map_err(|error| format!("package list for {package} must be UTF-8: {error}"))?; + let mut paths = BTreeSet::new(); + for raw in stdout.lines() { + let path = validated_relative_path(raw, &format!("package list for {package}"))?; + if !paths.insert(path) { + return Err(format!( + "package list for {package} contains a duplicate path" + )); + } + } + if paths.is_empty() { + return Err(format!("package list for {package} must not be empty")); + } + Ok(paths) +} + +fn run_cargo(directory: &Path, args: &[&str], purpose: &str) -> Result<(), String> { + cargo_output(directory, args, purpose).map(|_| ()) +} + +fn cargo_output(directory: &Path, args: &[&str], purpose: &str) -> Result<Output, String> { + let output = Command::new("cargo") + .args(args) + .current_dir(directory) + .output() + .map_err(|error| format!("{purpose}: run cargo {}: {error}", args.join(" ")))?; + if !output.status.success() { + return Err(format!( + "{purpose}: cargo {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(output) +} + +fn require_regular_file(path: &Path, label: &str) -> Result<(), String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { + return Err(format!( + "{label} {} must be a nonempty regular file", + path.display() + )); + } + Ok(()) +} + +fn extract_and_validate_archive( + archive_path: &Path, + destination: &Path, + package: &str, + version: &str, + expected_files: &BTreeSet<String>, +) -> Result<(), String> { + if destination.exists() { + return Err(format!( + "archive extraction destination {} already exists", + destination.display() + )); + } + fs::create_dir_all(destination) + .map_err(|error| format!("create {}: {error}", destination.display()))?; + let expected_root = format!("{package}-{version}"); + let mut archived_files = BTreeSet::new(); + let archive_entries = tar_archive_entries(archive_path)?; + for (archive_entry, entry_type) in archive_entries { + let relative = archive_relative_path(&archive_entry, &expected_root)?; + if entry_type == 'd' { + continue; + } + if relative.as_os_str().is_empty() { + return Err("package archive root entry is not a package file".to_owned()); + } + if entry_type != '-' { + return Err(format!( + "package archive {} contains a non-file entry {}", + archive_path.display(), + archive_entry.display() + )); + } + let relative_string = path_to_slash_string(&relative)?; + reject_disposable_package_path(&relative_string)?; + if !archived_files.insert(relative_string.clone()) { + return Err(format!( + "package archive {} contains duplicate path {relative_string}", + archive_path.display() + )); + } + } + if &archived_files != expected_files { + let missing = expected_files + .difference(&archived_files) + .cloned() + .collect::<Vec<_>>(); + let extra = archived_files + .difference(expected_files) + .cloned() + .collect::<Vec<_>>(); + return Err(format!( + "package list/archive mismatch for {package}; missing: {}; extra: {}", + missing.join(", "), + extra.join(", ") + )); + } + for required in ["Cargo.toml", "Cargo.toml.orig", "README"] { + if !archived_files.contains(required) { + return Err(format!( + "package archive for {package} is missing {required}" + )); + } + } + if !archived_files.iter().any(|path| path.starts_with("src/")) { + return Err(format!("package archive for {package} has no src files")); + } + let extraction = Command::new("tar") + .args(["-xf"]) + .arg(archive_path) + .args(["-C"]) + .arg(destination) + .arg("--strip-components=1") + .output() + .map_err(|error| format!("extract {} with tar: {error}", archive_path.display()))?; + if !extraction.status.success() { + return Err(format!( + "extract {} with tar failed: {}", + archive_path.display(), + String::from_utf8_lossy(&extraction.stderr).trim() + )); + } + let extracted_files = collect_extracted_files(destination)?; + if extracted_files != archived_files { + return Err(format!( + "extracted package inventory for {package} differs from its archive" + )); + } + Ok(()) +} + +fn tar_archive_entries(archive_path: &Path) -> Result<Vec<(PathBuf, char)>, String> { + let paths = Command::new("tar") + .args(["-tf"]) + .arg(archive_path) + .output() + .map_err(|error| format!("list {} with tar: {error}", archive_path.display()))?; + if !paths.status.success() { + return Err(format!( + "list {} with tar failed: {}", + archive_path.display(), + String::from_utf8_lossy(&paths.stderr).trim() + )); + } + let verbose = Command::new("tar") + .args(["-tvf"]) + .arg(archive_path) + .output() + .map_err(|error| format!("inspect {} with tar: {error}", archive_path.display()))?; + if !verbose.status.success() { + return Err(format!( + "inspect {} with tar failed: {}", + archive_path.display(), + String::from_utf8_lossy(&verbose.stderr).trim() + )); + } + let path_output = String::from_utf8(paths.stdout) + .map_err(|error| format!("archive paths must be UTF-8: {error}"))?; + let verbose_output = String::from_utf8(verbose.stdout) + .map_err(|error| format!("archive metadata must be UTF-8: {error}"))?; + let path_lines = path_output.lines().collect::<Vec<_>>(); + let type_lines = verbose_output.lines().collect::<Vec<_>>(); + if path_lines.len() != type_lines.len() || path_lines.is_empty() { + return Err(format!( + "archive {} path and type inventories differ or are empty", + archive_path.display() + )); + } + path_lines + .into_iter() + .zip(type_lines) + .map(|(path, metadata)| { + let entry_type = metadata + .chars() + .next() + .ok_or_else(|| "tar emitted empty archive metadata".to_owned())?; + Ok((PathBuf::from(path), entry_type)) + }) + .collect() +} + +fn archive_relative_path(path: &Path, expected_root: &str) -> Result<PathBuf, String> { + let mut components = path.components(); + match components.next() { + Some(Component::Normal(root)) if root == expected_root => {} + _ => { + return Err(format!( + "package archive entry {} is outside root {expected_root}", + path.display() + )); + } + } + let mut relative = PathBuf::new(); + for component in components { + match component { + Component::Normal(value) => relative.push(value), + _ => { + return Err(format!( + "package archive entry {} contains an unsafe component", + path.display() + )); + } + } + } + Ok(relative) +} + +fn validated_relative_path(raw: &str, label: &str) -> Result<String, String> { + if raw.is_empty() || raw.contains('\\') { + return Err(format!("{label} contains an invalid path {raw:?}")); + } + let path = Path::new(raw); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("{label} contains an unsafe path {raw:?}")); + } + reject_disposable_package_path(raw)?; + Ok(raw.to_owned()) +} + +fn reject_disposable_package_path(path: &str) -> Result<(), String> { + let first = path.split('/').next().unwrap_or(path); + if matches!(first, ".git" | ".beads" | "target" | "result" | ".local") + || matches!( + path, + "status_extbuild_resume_usage.txt" + | "status_radroots_ios_phase_1_foundational_events_update_6_2026-07-26.md" + | "handoff-radroots-ios-phase-1-foundational-events-2026-07-26.txt" + | "prompt-resume-radroots-ios-phase-1-foundational-events.txt" + ) + { + return Err(format!("package inventory contains disposable path {path}")); + } + Ok(()) +} + +fn path_to_slash_string(path: &Path) -> Result<String, String> { + path.components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .map(str::to_owned) + .ok_or_else(|| "package archive paths must be UTF-8".to_owned()), + _ => Err("package archive path must be relative".to_owned()), + }) + .collect::<Result<Vec<_>, _>>() + .map(|parts| parts.join("/")) +} + +fn collect_extracted_files(root: &Path) -> Result<BTreeSet<String>, String> { + fn visit(root: &Path, directory: &Path, files: &mut BTreeSet<String>) -> Result<(), String> { + for entry in fs::read_dir(directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + { + let entry = entry.map_err(|error| format!("read extracted entry: {error}"))?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "extracted package contains symlink {}", + entry.path().display() + )); + } + if metadata.is_dir() { + visit(root, &entry.path(), files)?; + } else if metadata.is_file() { + let relative = entry + .path() + .strip_prefix(root) + .map_err(|error| format!("resolve extracted path: {error}"))? + .to_path_buf(); + files.insert(path_to_slash_string(&relative)?); + } else { + return Err(format!( + "extracted package contains non-file {}", + entry.path().display() + )); + } + } + Ok(()) + } + let mut files = BTreeSet::new(); + visit(root, root, &mut files)?; + Ok(files) +} + +fn validate_normalized_manifest( + manifest_path: &Path, + expected_package: &str, + release_version: &str, + public: &BTreeSet<String>, +) -> Result<(), String> { + let raw = fs::read_to_string(manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = toml::from_str::<toml::Value>(&raw) + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + let package = manifest + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{} has no package table", manifest_path.display()))?; + if package.get("name").and_then(toml::Value::as_str) != Some(expected_package) + || package.get("version").and_then(toml::Value::as_str) != Some(release_version) + { + return Err(format!( + "normalized manifest for {expected_package} has wrong package identity" + )); + } + let publish = package + .get("publish") + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("normalized manifest for {expected_package} has no publish list"))?; + if publish.as_slice() != [toml::Value::String("crates-io".to_owned())] { + return Err(format!( + "normalized manifest for {expected_package} is not crates.io-only" + )); + } + + for section in ["dependencies", "build-dependencies"] { + validate_dependency_table( + manifest.get(section), + expected_package, + section, + release_version, + public, + true, + )?; + } + validate_dependency_table( + manifest.get("dev-dependencies"), + expected_package, + "dev-dependencies", + release_version, + public, + false, + )?; + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for (target, target_value) in targets { + let target_table = target_value + .as_table() + .ok_or_else(|| format!("normalized manifest target {target} must be a table"))?; + for section in ["dependencies", "build-dependencies"] { + validate_dependency_table( + target_table.get(section), + expected_package, + &format!("target.{target}.{section}"), + release_version, + public, + true, + )?; + } + validate_dependency_table( + target_table.get("dev-dependencies"), + expected_package, + &format!("target.{target}.dev-dependencies"), + release_version, + public, + false, + )?; + } + } + Ok(()) +} + +fn validate_dependency_table( + value: Option<&toml::Value>, + owner: &str, + section: &str, + release_version: &str, + public: &BTreeSet<String>, + require_public_radroots: bool, +) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + let table = value.as_table().ok_or_else(|| { + format!("normalized manifest {owner} {section} must be a dependency table") + })?; + for (key, dependency) in table { + let (package, version) = match dependency { + toml::Value::String(version) => (key.as_str(), version.as_str()), + toml::Value::Table(configuration) => { + for forbidden in ["path", "git", "branch", "rev", "tag"] { + if configuration.contains_key(forbidden) { + return Err(format!( + "normalized manifest {owner} {section}.{key} contains forbidden {forbidden} source authority" + )); + } + } + let package = configuration + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(key); + let version = configuration + .get("version") + .and_then(toml::Value::as_str) + .ok_or_else(|| { + format!( + "normalized manifest {owner} {section}.{key} has no registry version" + ) + })?; + (package, version) + } + _ => { + return Err(format!( + "normalized manifest {owner} {section}.{key} has invalid dependency configuration" + )); + } + }; + if package.starts_with("radroots_") { + if require_public_radroots && !public.contains(package) { + return Err(format!( + "normalized manifest {owner} {section}.{key} references non-public package {package}" + )); + } + if version != format!("={release_version}") { + return Err(format!( + "normalized manifest {owner} {section}.{key} must pin {package} to ={release_version}" + )); + } + } + } + Ok(()) +} + +fn load_workspace_packages(workspace_root: &Path) -> Result<Vec<PackageWorkspaceEntry>, String> { + let output = cargo_output( + workspace_root, + &["metadata", "--locked", "--format-version", "1"], + "load release package workspace metadata", + )?; + let metadata = serde_json::from_slice::<CargoMetadata>(&output.stdout) + .map_err(|error| format!("parse cargo metadata for release packages: {error}"))?; + let members = metadata + .workspace_members + .into_iter() + .collect::<BTreeSet<_>>(); + let mut packages = Vec::new(); + let mut found = BTreeSet::new(); + for package in metadata.packages { + if !members.contains(&package.id) { + continue; + } + found.insert(package.id); + let source_directory = package + .manifest_path + .parent() + .ok_or_else(|| format!("package {} manifest has no parent", package.name))? + .to_path_buf(); + packages.push(PackageWorkspaceEntry { + name: package.name, + source_directory, + }); + } + if found != members { + return Err("cargo metadata omitted one or more workspace package records".to_owned()); + } + packages.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(packages) +} + +fn write_archive_workspace( + workspace_root: &Path, + extracted_root: &Path, + packages: &[PackageWorkspaceEntry], + public: &BTreeSet<String>, +) -> Result<(), String> { + let mut manifest = String::from("[workspace]\nresolver = \"2\"\nmembers = [\n"); + for package in public { + manifest.push_str(&format!(" {:?},\n", extracted_root.join(package))); + } + manifest.push_str("]\n\n[patch.crates-io]\n"); + for package in packages { + let path = if public.contains(&package.name) { + extracted_root.join(&package.name) + } else { + package.source_directory.clone() + }; + manifest.push_str(&format!("{} = {{ path = {:?} }}\n", package.name, path)); + } + fs::write(workspace_root.join("Cargo.toml"), manifest) + .map_err(|error| format!("write archive workspace manifest: {error}")) +} + +fn ensure_clean_git_worktree(workspace_root: &Path) -> Result<(), String> { + let output = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .current_dir(workspace_root) + .output() + .map_err(|error| format!("inspect release package Git worktree: {error}"))?; + if !output.status.success() { + return Err(format!( + "inspect release package Git worktree failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + if !output.stdout.is_empty() { + return Err("release package preflight requires an exact clean Git worktree".to_owned()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_package_paths_reject_escapes_and_disposable_files() { + assert_eq!( + validated_relative_path("src/lib.rs", "fixture").expect("valid path"), + "src/lib.rs" + ); + for invalid in [ + "", + "/absolute", + "../escape", + "src/../escape", + "target/output", + ".beads/state", + "status_extbuild_resume_usage.txt", + ] { + assert!(validated_relative_path(invalid, "fixture").is_err()); + } + } + + #[test] + fn normalized_manifest_requires_public_exact_registry_dependencies() { + let root = tempfile::tempdir().expect("temporary manifest root"); + let manifest = root.path().join("Cargo.toml"); + fs::write( + &manifest, + r#"[package] +name = "radroots_public" +version = "1.0.0-alpha.1" +publish = ["crates-io"] + +[dependencies.public_alias] +package = "radroots_dependency" +version = "=1.0.0-alpha.1" +optional = true + +[target.'cfg(unix)'.build-dependencies.serde] +version = "1" + +[dev-dependencies.radroots_internal] +version = "=1.0.0-alpha.1" +"#, + ) + .expect("write manifest"); + let public = BTreeSet::from([ + "radroots_dependency".to_owned(), + "radroots_public".to_owned(), + ]); + validate_normalized_manifest(&manifest, "radroots_public", "1.0.0-alpha.1", &public) + .expect("valid normalized manifest"); + + let invalid = fs::read_to_string(&manifest) + .expect("read manifest") + .replace("radroots_dependency", "radroots_internal"); + fs::write(&manifest, invalid).expect("write invalid manifest"); + assert!( + validate_normalized_manifest(&manifest, "radroots_public", "1.0.0-alpha.1", &public,) + .expect_err("internal production dependency must fail") + .contains("non-public package radroots_internal") + ); + } + + #[test] + fn archive_extraction_matches_the_exact_package_list() { + let root = tempfile::tempdir().expect("temporary archive root"); + let package_root = root.path().join("radroots_fixture-1.0.0"); + fs::create_dir_all(package_root.join("src")).expect("create package fixture"); + fs::write( + package_root.join("Cargo.toml"), + "[package]\nname='radroots_fixture'\nversion='1.0.0'\npublish=['crates-io']\n", + ) + .expect("write Cargo manifest"); + fs::write(package_root.join("Cargo.toml.orig"), "original") + .expect("write original manifest"); + fs::write(package_root.join("README"), "readme").expect("write README"); + fs::write(package_root.join("src/lib.rs"), "pub fn fixture() {}").expect("write source"); + let archive_path = root.path().join("radroots_fixture-1.0.0.crate"); + let status = Command::new("tar") + .args(["-czf"]) + .arg(&archive_path) + .args(["-C"]) + .arg(root.path()) + .arg("radroots_fixture-1.0.0") + .status() + .expect("create fixture archive with tar"); + assert!(status.success()); + + let expected = BTreeSet::from([ + "Cargo.toml".to_owned(), + "Cargo.toml.orig".to_owned(), + "README".to_owned(), + "src/lib.rs".to_owned(), + ]); + let destination = root.path().join("extracted"); + extract_and_validate_archive( + &archive_path, + &destination, + "radroots_fixture", + "1.0.0", + &expected, + ) + .expect("extract exact archive"); + let contents = + fs::read_to_string(destination.join("README")).expect("read extracted README"); + assert_eq!(contents, "readme"); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -87,7 +87,10 @@ fn release_preflight() -> Result<(), String> { fn release_preflight_at(root: &Path) -> Result<(), String> { dto_roots::check(root)?; contract::validate_artifact_contracts(root)?; - contract::validate_release_preflight(root) + contract::validate_release_preflight(root)?; + #[cfg(not(test))] + contract::validate_release_packages(root)?; + Ok(()) } fn run_release(args: &[String]) -> Result<(), String> {