commit 5ff49ddc6d28ca32cc798f4abf1cfb8c7112229d
parent 1c4b3f3cae1181fa53bb777237f281b559082203
Author: triesap <tyson@radroots.org>
Date: Mon, 7 Sep 2026 06:37:37 +0000
feat: add reproducibility and install harness
- Create two clean detached checkouts with distinct build stores.
- Bind build epochs to the exact root-preimage commit timestamp.
- Compare closed identity-only artifact inventories with bounded diffs.
- Enforce fresh install, upgrade, rollback, and health witness chains.
Diffstat:
3 files changed, 1808 insertions(+), 0 deletions(-)
diff --git a/contracts/architecture/decisions/services_hardening_repro_install.v1.json b/contracts/architecture/decisions/services_hardening_repro_install.v1.json
@@ -0,0 +1,82 @@
+{
+ "schema": "radroots.services-hardening.repro-install-decisions.v1",
+ "contract_version": 1,
+ "decision_state": "active",
+ "owner_step": 306,
+ "command": "cargo xtask service-repro-install",
+ "required_arguments": [
+ "plan",
+ "source_root",
+ "root_preimage_root",
+ "predecessor_artifact_root",
+ "git_executable",
+ "adapter_executable",
+ "output"
+ ],
+ "plan_schema": "radroots.services-hardening.repro-install-plan.v1",
+ "result_schema": "radroots.services-hardening.repro-install-result.v1",
+ "phase_witness_schema": "radroots.services-hardening.repro-install-phase-witness.v1",
+ "candidate_binding": "explicit_sha256_candidate_identity_digest",
+ "source_binding": "exact_clean_git_revision_and_tree",
+ "checkout_policy": "two_fresh_detached_no_local_no_checkout_clones",
+ "checkout_count": 2,
+ "store_policy": "one_distinct_empty_harness_owned_store_per_build",
+ "source_date_epoch": "exact_root_preimage_commit_timestamp",
+ "normalization": "identity_exact_bytes_v1_no_exclusions",
+ "reproducibility_comparison": "exact_relative_path_mode_size_and_sha256_inventory",
+ "tool_binding": "exact_regular_executable_bytes_sha256",
+ "process_policy": "bounded_process_group_closed_stdin_replacement_environment",
+ "result_write_policy": "create_new_regular_file_only_with_failure_diff_preserved",
+ "install_roots": "distinct_fresh_and_lifecycle_harness_owned_roots",
+ "phase_inventory": [
+ "fresh_install_candidate",
+ "fresh_health_candidate",
+ "install_predecessor",
+ "pre_upgrade_health",
+ "upgrade_candidate",
+ "post_upgrade_health",
+ "rollback_predecessor",
+ "post_rollback_health"
+ ],
+ "phase_state_policy": "canonical_witnesses_form_two_closed_state_chains_and_health_is_nonmutating",
+ "maximums": {
+ "plan_bytes": 1048576,
+ "result_bytes": 4194304,
+ "process_stream_bytes": 33554432,
+ "process_deadline_seconds": 3600,
+ "artifact_files": 65536,
+ "artifact_total_bytes": 17179869184,
+ "artifact_file_bytes": 2147483648,
+ "diff_entries": 256,
+ "argv_tokens": 128,
+ "argv_token_bytes": 4096
+ },
+ "required_negative_vectors": [
+ "dirty_source",
+ "wrong_source_tree",
+ "wrong_root_preimage_epoch",
+ "reused_checkout_or_store",
+ "artifact_path_mode_size_or_digest_mismatch",
+ "open_normalization",
+ "missing_or_reordered_phase",
+ "noncanonical_witness",
+ "mutating_health_witness",
+ "broken_upgrade_or_rollback_chain",
+ "replaced_tool_executable",
+ "preexisting_output"
+ ],
+ "negative_error_codes": [
+ "invalid_contract",
+ "invalid_plan",
+ "invalid_source",
+ "dirty_source",
+ "invalid_tool",
+ "checkout_failure",
+ "build_failure",
+ "invalid_artifacts",
+ "reproducibility_mismatch",
+ "install_failure",
+ "invalid_witness",
+ "invalid_output"
+ ]
+}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -52,6 +52,7 @@ mod safety_qualification;
mod sdk_generation;
mod service_build_qualification;
mod service_release_artifacts;
+mod service_repro_install;
mod service_source_lock;
mod service_source_lock_command;
mod service_source_lock_v3;
@@ -236,6 +237,22 @@ enum XtaskCommand {
#[arg(long)]
candidate_digest: String,
},
+ ServiceReproInstall {
+ #[arg(long)]
+ plan: PathBuf,
+ #[arg(long)]
+ source_root: PathBuf,
+ #[arg(long)]
+ root_preimage_root: PathBuf,
+ #[arg(long)]
+ predecessor_artifact_root: PathBuf,
+ #[arg(long)]
+ git_executable: PathBuf,
+ #[arg(long)]
+ adapter_executable: PathBuf,
+ #[arg(long)]
+ output: PathBuf,
+ },
Source {
#[command(subcommand)]
command: SourceCommand,
@@ -444,6 +461,9 @@ fn usage() {
" cargo xtask service-release-artifacts --mode <check|write> --service-root <absolute-directory> --lib-root <absolute-directory> --input-root <absolute-directory> --output-root <absolute-directory> --target <rust-target> --source-date-epoch <seconds> --candidate-digest <sha256>"
);
eprintln!(
+ " cargo xtask service-repro-install --plan <absolute-file> --source-root <absolute-directory> --root-preimage-root <absolute-directory> --predecessor-artifact-root <absolute-directory> --git-executable <absolute-file> --adapter-executable <absolute-file> --output <absolute-file>"
+ );
+ eprintln!(
" cargo xtask source materialize --consumer-root <absolute-directory> --cache-root <absolute-directory> --mode <prefetch|offline>"
);
eprintln!(" cargo xtask source archive-verify --archive <bundle> --sha256 <digest>");
@@ -493,6 +513,7 @@ fn validate_contract() -> Result<(), String> {
service_source_lock::validate_contract(&root)?;
service_build_qualification::validate_contract(&root)?;
service_release_artifacts::validate_contract(&root)?;
+ service_repro_install::validate_contract(&root)?;
dto_roots::check(&root)?;
generate::protocol::check(&root)?;
contract::load_contract_bundle(&root)
@@ -519,6 +540,7 @@ fn release_preflight_at(root: &Path) -> Result<(), String> {
}
LaneId::ServiceReleaseArtifactsContract => {
service_release_artifacts::validate_contract(root)
+ .and_then(|()| service_repro_install::validate_contract(root))
}
LaneId::PublicNativeGroup => build_control::group_plan(
root,
@@ -760,6 +782,26 @@ fn run(args: &[String]) -> Result<(), String> {
source_date_epoch,
candidate_digest: &candidate_digest,
}),
+ XtaskCommand::ServiceReproInstall {
+ plan,
+ source_root,
+ root_preimage_root,
+ predecessor_artifact_root,
+ git_executable,
+ adapter_executable,
+ output,
+ } => service_repro_install::run(
+ &workspace_root(),
+ service_repro_install::Arguments {
+ plan: &plan,
+ source_root: &source_root,
+ root_preimage_root: &root_preimage_root,
+ predecessor_artifact_root: &predecessor_artifact_root,
+ git_executable: &git_executable,
+ adapter_executable: &adapter_executable,
+ output: &output,
+ },
+ ),
XtaskCommand::Source { command } => match command {
SourceCommand::Materialize {
consumer_root,
@@ -964,6 +1006,8 @@ mod tests {
"check",
"--service-root",
"/tmp/service",
+ "--lib-root",
+ "/tmp/lib",
"--input-root",
"/tmp/input",
"--output-root",
@@ -977,6 +1021,27 @@ mod tests {
])
.is_ok()
);
+ assert!(
+ Cli::try_parse_from([
+ "xtask",
+ "service-repro-install",
+ "--plan",
+ "/tmp/plan.json",
+ "--source-root",
+ "/tmp/source",
+ "--root-preimage-root",
+ "/tmp/root",
+ "--predecessor-artifact-root",
+ "/tmp/predecessor",
+ "--git-executable",
+ "/usr/bin/git",
+ "--adapter-executable",
+ "/tmp/adapter",
+ "--output",
+ "/tmp/result.json",
+ ])
+ .is_ok()
+ );
assert!(
Cli::try_parse_from([
diff --git a/tools/xtask/src/service_repro_install.rs b/tools/xtask/src/service_repro_install.rs
@@ -0,0 +1,1661 @@
+use std::collections::{BTreeMap, BTreeSet};
+use std::ffi::OsString;
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::path::{Component, Path, PathBuf};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use sha2::{Digest as _, Sha256};
+
+use crate::bounded_process::{self, ProcessOutput, ProcessRequest, ReplacementEnvironment};
+
+const CONTRACT_RELATIVE: &str =
+ "contracts/architecture/decisions/services_hardening_repro_install.v1.json";
+const PLAN_SCHEMA: &str = "radroots.services-hardening.repro-install-plan.v1";
+const RESULT_SCHEMA: &str = "radroots.services-hardening.repro-install-result.v1";
+const WITNESS_SCHEMA: &str = "radroots.services-hardening.repro-install-phase-witness.v1";
+const NORMALIZATION_KIND: &str = "identity_exact_bytes_v1";
+const MAX_PLAN_BYTES: u64 = 1024 * 1024;
+const MAX_RESULT_BYTES: usize = 4 * 1024 * 1024;
+const MAX_PROCESS_STREAM_BYTES: usize = 32 * 1024 * 1024;
+const MAX_PROCESS_DEADLINE_SECONDS: u64 = 3600;
+const MAX_ARTIFACT_FILES: usize = 65_536;
+const MAX_ARTIFACT_TOTAL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
+const MAX_ARTIFACT_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
+const MAX_DIFF_ENTRIES: usize = 256;
+const MAX_ARGV_TOKENS: usize = 128;
+const MAX_ARGV_TOKEN_BYTES: usize = 4096;
+const EMPTY_STATE: &str = "empty";
+const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"];
+const BUILD_PLACEHOLDERS: [&str; 6] = [
+ "{checkout}",
+ "{store}",
+ "{output}",
+ "{source_date_epoch}",
+ "{candidate_digest}",
+ "{target}",
+];
+const PHASE_PLACEHOLDERS: [&str; 7] = [
+ "{phase}",
+ "{install_root}",
+ "{artifact_root}",
+ "{artifact_set_sha256}",
+ "{source_date_epoch}",
+ "{candidate_digest}",
+ "{target}",
+];
+const PHASE_IDS: [&str; 8] = [
+ "fresh_install_candidate",
+ "fresh_health_candidate",
+ "install_predecessor",
+ "pre_upgrade_health",
+ "upgrade_candidate",
+ "post_upgrade_health",
+ "rollback_predecessor",
+ "post_rollback_health",
+];
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum ReproInstallError {
+ InvalidContract,
+ InvalidPlan,
+ InvalidSource,
+ DirtySource,
+ InvalidTool,
+ CheckoutFailure,
+ BuildFailure,
+ InvalidArtifacts,
+ ReproducibilityMismatch,
+ InstallFailure,
+ InvalidWitness,
+ InvalidOutput,
+}
+
+impl ReproInstallError {
+ fn code(self) -> &'static str {
+ match self {
+ Self::InvalidContract => "invalid_contract",
+ Self::InvalidPlan => "invalid_plan",
+ Self::InvalidSource => "invalid_source",
+ Self::DirtySource => "dirty_source",
+ Self::InvalidTool => "invalid_tool",
+ Self::CheckoutFailure => "checkout_failure",
+ Self::BuildFailure => "build_failure",
+ Self::InvalidArtifacts => "invalid_artifacts",
+ Self::ReproducibilityMismatch => "reproducibility_mismatch",
+ Self::InstallFailure => "install_failure",
+ Self::InvalidWitness => "invalid_witness",
+ Self::InvalidOutput => "invalid_output",
+ }
+ }
+}
+
+impl std::fmt::Display for ReproInstallError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str(self.code())
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct Arguments<'a> {
+ pub(crate) plan: &'a Path,
+ pub(crate) source_root: &'a Path,
+ pub(crate) root_preimage_root: &'a Path,
+ pub(crate) predecessor_artifact_root: &'a Path,
+ pub(crate) git_executable: &'a Path,
+ pub(crate) adapter_executable: &'a Path,
+ pub(crate) output: &'a Path,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Plan {
+ schema: String,
+ candidate_digest: String,
+ target: String,
+ source_revision: String,
+ source_tree: String,
+ root_preimage_revision: String,
+ source_date_epoch: u64,
+ normalization: Normalization,
+ git_executable_sha256: String,
+ adapter_executable_sha256: String,
+ build_argv: Vec<String>,
+ phase: Vec<PhasePlan>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Normalization {
+ kind: String,
+ excluded_paths: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PhasePlan {
+ id: String,
+ argv: Vec<String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+struct PhaseWitness {
+ schema: String,
+ phase: String,
+ candidate_digest: String,
+ artifact_set_sha256: String,
+ before_state_sha256: String,
+ after_state_sha256: String,
+ result: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+struct ArtifactEntry {
+ path: String,
+ mode: u32,
+ size_bytes: u64,
+ sha256: String,
+}
+
+#[derive(Debug)]
+struct ArtifactInventory {
+ entries: Vec<ArtifactEntry>,
+ sha256: String,
+ total_bytes: u64,
+}
+
+#[derive(Debug, Serialize)]
+struct ArtifactDifference {
+ path: String,
+ first: Option<ArtifactEntry>,
+ second: Option<ArtifactEntry>,
+}
+
+pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> {
+ validate_contract_inner(workspace_root).map_err(|error| error.to_string())
+}
+
+fn validate_contract_inner(workspace_root: &Path) -> Result<(), ReproInstallError> {
+ let bytes = read_regular_bounded(
+ &workspace_root.join(CONTRACT_RELATIVE),
+ MAX_PLAN_BYTES,
+ ReproInstallError::InvalidContract,
+ )?;
+ let observed =
+ serde_json::from_slice::<Value>(&bytes).map_err(|_| ReproInstallError::InvalidContract)?;
+ if observed != expected_contract() {
+ return Err(ReproInstallError::InvalidContract);
+ }
+ Ok(())
+}
+
+fn expected_contract() -> Value {
+ json!({
+ "schema": "radroots.services-hardening.repro-install-decisions.v1",
+ "contract_version": 1,
+ "decision_state": "active",
+ "owner_step": 306,
+ "command": "cargo xtask service-repro-install",
+ "required_arguments": ["plan", "source_root", "root_preimage_root", "predecessor_artifact_root", "git_executable", "adapter_executable", "output"],
+ "plan_schema": PLAN_SCHEMA,
+ "result_schema": RESULT_SCHEMA,
+ "phase_witness_schema": WITNESS_SCHEMA,
+ "candidate_binding": "explicit_sha256_candidate_identity_digest",
+ "source_binding": "exact_clean_git_revision_and_tree",
+ "checkout_policy": "two_fresh_detached_no_local_no_checkout_clones",
+ "checkout_count": 2,
+ "store_policy": "one_distinct_empty_harness_owned_store_per_build",
+ "source_date_epoch": "exact_root_preimage_commit_timestamp",
+ "normalization": "identity_exact_bytes_v1_no_exclusions",
+ "reproducibility_comparison": "exact_relative_path_mode_size_and_sha256_inventory",
+ "tool_binding": "exact_regular_executable_bytes_sha256",
+ "process_policy": "bounded_process_group_closed_stdin_replacement_environment",
+ "result_write_policy": "create_new_regular_file_only_with_failure_diff_preserved",
+ "install_roots": "distinct_fresh_and_lifecycle_harness_owned_roots",
+ "phase_inventory": PHASE_IDS,
+ "phase_state_policy": "canonical_witnesses_form_two_closed_state_chains_and_health_is_nonmutating",
+ "maximums": {
+ "plan_bytes": MAX_PLAN_BYTES,
+ "result_bytes": MAX_RESULT_BYTES,
+ "process_stream_bytes": MAX_PROCESS_STREAM_BYTES,
+ "process_deadline_seconds": MAX_PROCESS_DEADLINE_SECONDS,
+ "artifact_files": MAX_ARTIFACT_FILES,
+ "artifact_total_bytes": MAX_ARTIFACT_TOTAL_BYTES,
+ "artifact_file_bytes": MAX_ARTIFACT_FILE_BYTES,
+ "diff_entries": MAX_DIFF_ENTRIES,
+ "argv_tokens": MAX_ARGV_TOKENS,
+ "argv_token_bytes": MAX_ARGV_TOKEN_BYTES
+ },
+ "required_negative_vectors": [
+ "dirty_source", "wrong_source_tree", "wrong_root_preimage_epoch",
+ "reused_checkout_or_store", "artifact_path_mode_size_or_digest_mismatch",
+ "open_normalization", "missing_or_reordered_phase", "noncanonical_witness",
+ "mutating_health_witness", "broken_upgrade_or_rollback_chain",
+ "replaced_tool_executable", "preexisting_output"
+ ],
+ "negative_error_codes": [
+ "invalid_contract", "invalid_plan", "invalid_source", "dirty_source",
+ "invalid_tool", "checkout_failure", "build_failure", "invalid_artifacts",
+ "reproducibility_mismatch", "install_failure", "invalid_witness", "invalid_output"
+ ]
+ })
+}
+
+pub(crate) fn run(workspace_root: &Path, arguments: Arguments<'_>) -> Result<(), String> {
+ run_inner(workspace_root, arguments).map_err(|error| error.to_string())
+}
+
+fn run_inner(workspace_root: &Path, arguments: Arguments<'_>) -> Result<(), ReproInstallError> {
+ validate_contract_inner(workspace_root)?;
+ validate_output_target(arguments.output)?;
+ let plan = load_plan(arguments.plan)?;
+ validate_plan(&plan)?;
+ validate_executable(arguments.git_executable, &plan.git_executable_sha256)?;
+ validate_executable(
+ arguments.adapter_executable,
+ &plan.adapter_executable_sha256,
+ )?;
+
+ let source_root = canonical_directory(arguments.source_root, ReproInstallError::InvalidSource)?;
+ let root_preimage_root = canonical_directory(
+ arguments.root_preimage_root,
+ ReproInstallError::InvalidSource,
+ )?;
+ let predecessor_root = canonical_directory(
+ arguments.predecessor_artifact_root,
+ ReproInstallError::InvalidArtifacts,
+ )?;
+ let temporary = tempfile::Builder::new()
+ .prefix("radroots-repro-install-")
+ .tempdir()
+ .map_err(|_| ReproInstallError::InvalidOutput)?;
+ let harness_root = temporary
+ .path()
+ .canonicalize()
+ .map_err(|_| ReproInstallError::InvalidOutput)?;
+ let home = create_owned_directory(&harness_root.join("home"))?;
+
+ verify_source(
+ arguments.git_executable,
+ &source_root,
+ &home,
+ &plan.source_revision,
+ &plan.source_tree,
+ )?;
+ verify_root_preimage_epoch(
+ arguments.git_executable,
+ &root_preimage_root,
+ &home,
+ &plan.root_preimage_revision,
+ plan.source_date_epoch,
+ )?;
+
+ let checkout_one = harness_root.join("checkout-1");
+ let checkout_two = harness_root.join("checkout-2");
+ create_checkout(
+ arguments.git_executable,
+ &source_root,
+ &checkout_one,
+ &home,
+ &plan,
+ )?;
+ create_checkout(
+ arguments.git_executable,
+ &source_root,
+ &checkout_two,
+ &home,
+ &plan,
+ )?;
+ require_distinct(
+ &checkout_one,
+ &checkout_two,
+ ReproInstallError::CheckoutFailure,
+ )?;
+
+ let store_one = create_owned_directory(&harness_root.join("store-1"))?;
+ let store_two = create_owned_directory(&harness_root.join("store-2"))?;
+ let output_one = create_owned_directory(&harness_root.join("output-1"))?;
+ let output_two = create_owned_directory(&harness_root.join("output-2"))?;
+ require_distinct(&store_one, &store_two, ReproInstallError::BuildFailure)?;
+ require_distinct(&output_one, &output_two, ReproInstallError::BuildFailure)?;
+ run_build(
+ arguments.adapter_executable,
+ &plan,
+ &checkout_one,
+ &store_one,
+ &output_one,
+ &home,
+ )?;
+ run_build(
+ arguments.adapter_executable,
+ &plan,
+ &checkout_two,
+ &store_two,
+ &output_two,
+ &home,
+ )?;
+ let first = artifact_inventory(&output_one)?;
+ let second = artifact_inventory(&output_two)?;
+ let differences = compare_inventories(&first, &second);
+ if !differences.is_empty() {
+ let result = failure_result(&plan, &first, &second, &differences);
+ write_result(arguments.output, &result)?;
+ return Err(ReproInstallError::ReproducibilityMismatch);
+ }
+
+ let predecessor = artifact_inventory(&predecessor_root)?;
+ let fresh_root = create_owned_directory(&harness_root.join("install-fresh"))?;
+ let lifecycle_root = create_owned_directory(&harness_root.join("install-lifecycle"))?;
+ require_distinct(
+ &fresh_root,
+ &lifecycle_root,
+ ReproInstallError::InstallFailure,
+ )?;
+ let witnesses = run_install_phases(
+ arguments.adapter_executable,
+ &plan,
+ &output_one,
+ &first.sha256,
+ &predecessor_root,
+ &predecessor.sha256,
+ &fresh_root,
+ &lifecycle_root,
+ &home,
+ )?;
+ validate_phase_chain(
+ &witnesses,
+ &plan.candidate_digest,
+ &first.sha256,
+ &predecessor.sha256,
+ )?;
+
+ let result = json!({
+ "schema": RESULT_SCHEMA,
+ "candidate_digest": plan.candidate_digest,
+ "target": plan.target,
+ "source_revision": plan.source_revision,
+ "source_tree": plan.source_tree,
+ "root_preimage_revision": plan.root_preimage_revision,
+ "source_date_epoch": plan.source_date_epoch,
+ "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
+ "build": [
+ build_result("build-1", "checkout-1", "store-1", &first),
+ build_result("build-2", "checkout-2", "store-2", &second)
+ ],
+ "reproducibility": {
+ "first_inventory_sha256": first.sha256,
+ "second_inventory_sha256": second.sha256,
+ "difference": [],
+ "result": "pass"
+ },
+ "predecessor_artifact_set_sha256": predecessor.sha256,
+ "install_phase": witnesses,
+ "result": "pass"
+ });
+ write_result(arguments.output, &result)
+}
+
+fn load_plan(path: &Path) -> Result<Plan, ReproInstallError> {
+ let bytes = read_regular_bounded(path, MAX_PLAN_BYTES, ReproInstallError::InvalidPlan)?;
+ let value =
+ serde_json::from_slice::<Value>(&bytes).map_err(|_| ReproInstallError::InvalidPlan)?;
+ if canonical_json_line(&value).map_err(|_| ReproInstallError::InvalidPlan)? != bytes {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ serde_json::from_value(value).map_err(|_| ReproInstallError::InvalidPlan)
+}
+
+fn validate_plan(plan: &Plan) -> Result<(), ReproInstallError> {
+ if plan.schema != PLAN_SCHEMA
+ || !valid_hex(&plan.candidate_digest, 64)
+ || !SUPPORTED_TARGETS.contains(&plan.target.as_str())
+ || !valid_hex(&plan.source_revision, 40)
+ || !valid_hex(&plan.source_tree, 40)
+ || !valid_hex(&plan.root_preimage_revision, 40)
+ || plan.source_date_epoch == 0
+ || plan.normalization.kind != NORMALIZATION_KIND
+ || !plan.normalization.excluded_paths.is_empty()
+ || !valid_hex(&plan.git_executable_sha256, 64)
+ || !valid_hex(&plan.adapter_executable_sha256, 64)
+ || plan.phase.len() != PHASE_IDS.len()
+ || plan
+ .phase
+ .iter()
+ .map(|phase| phase.id.as_str())
+ .ne(PHASE_IDS)
+ {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ validate_argv_template(&plan.build_argv, &BUILD_PLACEHOLDERS)?;
+ for phase in &plan.phase {
+ validate_argv_template(&phase.argv, &PHASE_PLACEHOLDERS)?;
+ }
+ Ok(())
+}
+
+fn validate_argv_template(
+ arguments: &[String],
+ required: &[&str],
+) -> Result<(), ReproInstallError> {
+ if arguments.is_empty() || arguments.len() > MAX_ARGV_TOKENS {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ let required = required.iter().copied().collect::<BTreeSet<_>>();
+ let mut observed = BTreeSet::new();
+ for argument in arguments {
+ if argument.is_empty()
+ || argument.len() > MAX_ARGV_TOKEN_BYTES
+ || argument
+ .bytes()
+ .any(|byte| matches!(byte, 0 | b'\r' | b'\n'))
+ {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ if (argument.contains('{') || argument.contains('}'))
+ && (!required.contains(argument.as_str()) || !observed.insert(argument.as_str()))
+ {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ }
+ if observed != required {
+ return Err(ReproInstallError::InvalidPlan);
+ }
+ Ok(())
+}
+
+fn verify_source(
+ git: &Path,
+ source_root: &Path,
+ home: &Path,
+ revision: &str,
+ tree: &str,
+) -> Result<(), ReproInstallError> {
+ let status = git_output(
+ git,
+ source_root,
+ home,
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
+ )?;
+ if !status.stdout().is_empty() {
+ return Err(ReproInstallError::DirtySource);
+ }
+ if git_line(git, source_root, home, ["rev-parse", "HEAD"])? != revision
+ || git_line(git, source_root, home, ["rev-parse", "HEAD^{tree}"])? != tree
+ {
+ return Err(ReproInstallError::InvalidSource);
+ }
+ Ok(())
+}
+
+fn verify_root_preimage_epoch(
+ git: &Path,
+ root: &Path,
+ home: &Path,
+ revision: &str,
+ epoch: u64,
+) -> Result<(), ReproInstallError> {
+ let observed = git_line(git, root, home, ["show", "-s", "--format=%ct", revision])?
+ .parse::<u64>()
+ .map_err(|_| ReproInstallError::InvalidSource)?;
+ if observed != epoch {
+ return Err(ReproInstallError::InvalidSource);
+ }
+ Ok(())
+}
+
+fn create_checkout(
+ git: &Path,
+ source_root: &Path,
+ checkout: &Path,
+ home: &Path,
+ plan: &Plan,
+) -> Result<(), ReproInstallError> {
+ let source = os_string(source_root);
+ let destination = os_string(checkout);
+ let clone_arguments = [
+ OsString::from("-c"),
+ OsString::from("core.hooksPath=/dev/null"),
+ OsString::from("-c"),
+ OsString::from("protocol.file.allow=always"),
+ OsString::from("clone"),
+ OsString::from("--no-local"),
+ OsString::from("--no-checkout"),
+ OsString::from("--no-tags"),
+ source,
+ destination,
+ ];
+ run_process(
+ git,
+ &clone_arguments,
+ source_root,
+ git_environment(home)?,
+ 60,
+ ReproInstallError::CheckoutFailure,
+ )?;
+ let checkout = checkout
+ .canonicalize()
+ .map_err(|_| ReproInstallError::CheckoutFailure)?;
+ let checkout_arguments = [
+ OsString::from("-c"),
+ OsString::from("core.hooksPath=/dev/null"),
+ OsString::from("checkout"),
+ OsString::from("--detach"),
+ OsString::from(&plan.source_revision),
+ ];
+ run_process(
+ git,
+ &checkout_arguments,
+ &checkout,
+ git_environment(home)?,
+ 60,
+ ReproInstallError::CheckoutFailure,
+ )?;
+ verify_source(
+ git,
+ &checkout,
+ home,
+ &plan.source_revision,
+ &plan.source_tree,
+ )
+ .map_err(|_| ReproInstallError::CheckoutFailure)
+}
+
+fn run_build(
+ adapter: &Path,
+ plan: &Plan,
+ checkout: &Path,
+ store: &Path,
+ output: &Path,
+ home: &Path,
+) -> Result<(), ReproInstallError> {
+ require_empty_directory(store, ReproInstallError::BuildFailure)?;
+ require_empty_directory(output, ReproInstallError::BuildFailure)?;
+ let values = BTreeMap::from([
+ ("{checkout}", os_string(checkout)),
+ ("{store}", os_string(store)),
+ ("{output}", os_string(output)),
+ (
+ "{source_date_epoch}",
+ OsString::from(plan.source_date_epoch.to_string()),
+ ),
+ ("{candidate_digest}", OsString::from(&plan.candidate_digest)),
+ ("{target}", OsString::from(&plan.target)),
+ ]);
+ let arguments = resolve_arguments(&plan.build_argv, &values)?;
+ run_process(
+ adapter,
+ &arguments,
+ checkout,
+ harness_environment(home, plan, Some(store))?,
+ MAX_PROCESS_DEADLINE_SECONDS,
+ ReproInstallError::BuildFailure,
+ )?;
+ Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_install_phases(
+ adapter: &Path,
+ plan: &Plan,
+ candidate_root: &Path,
+ candidate_sha256: &str,
+ predecessor_root: &Path,
+ predecessor_sha256: &str,
+ fresh_root: &Path,
+ lifecycle_root: &Path,
+ home: &Path,
+) -> Result<Vec<PhaseWitness>, ReproInstallError> {
+ let mut witnesses = Vec::with_capacity(PHASE_IDS.len());
+ for phase in &plan.phase {
+ let uses_candidate = matches!(
+ phase.id.as_str(),
+ "fresh_install_candidate"
+ | "fresh_health_candidate"
+ | "upgrade_candidate"
+ | "post_upgrade_health"
+ );
+ let install_root = if phase.id.starts_with("fresh_") {
+ fresh_root
+ } else {
+ lifecycle_root
+ };
+ let artifact_root = if uses_candidate {
+ candidate_root
+ } else {
+ predecessor_root
+ };
+ let artifact_sha256 = if uses_candidate {
+ candidate_sha256
+ } else {
+ predecessor_sha256
+ };
+ let values = BTreeMap::from([
+ ("{phase}", OsString::from(&phase.id)),
+ ("{install_root}", os_string(install_root)),
+ ("{artifact_root}", os_string(artifact_root)),
+ ("{artifact_set_sha256}", OsString::from(artifact_sha256)),
+ (
+ "{source_date_epoch}",
+ OsString::from(plan.source_date_epoch.to_string()),
+ ),
+ ("{candidate_digest}", OsString::from(&plan.candidate_digest)),
+ ("{target}", OsString::from(&plan.target)),
+ ]);
+ let arguments = resolve_arguments(&phase.argv, &values)?;
+ let output = run_process(
+ adapter,
+ &arguments,
+ install_root,
+ harness_environment(home, plan, None)?,
+ MAX_PROCESS_DEADLINE_SECONDS,
+ ReproInstallError::InstallFailure,
+ )?;
+ let value = serde_json::from_slice::<Value>(output.stdout())
+ .map_err(|_| ReproInstallError::InvalidWitness)?;
+ if canonical_json_line(&value).map_err(|_| ReproInstallError::InvalidWitness)?
+ != output.stdout()
+ {
+ return Err(ReproInstallError::InvalidWitness);
+ }
+ let witness = serde_json::from_value::<PhaseWitness>(value)
+ .map_err(|_| ReproInstallError::InvalidWitness)?;
+ if witness.schema != WITNESS_SCHEMA
+ || witness.phase != phase.id
+ || witness.candidate_digest != plan.candidate_digest
+ || witness.artifact_set_sha256 != artifact_sha256
+ || witness.result != "pass"
+ || !valid_state(&witness.before_state_sha256)
+ || !valid_hex(&witness.after_state_sha256, 64)
+ {
+ return Err(ReproInstallError::InvalidWitness);
+ }
+ witnesses.push(witness);
+ }
+ Ok(witnesses)
+}
+
+fn validate_phase_chain(
+ witnesses: &[PhaseWitness],
+ candidate_digest: &str,
+ candidate_artifacts: &str,
+ predecessor_artifacts: &str,
+) -> Result<(), ReproInstallError> {
+ if witnesses.len() != PHASE_IDS.len()
+ || witnesses
+ .iter()
+ .map(|witness| witness.phase.as_str())
+ .ne(PHASE_IDS)
+ || witnesses.iter().any(|witness| {
+ witness.schema != WITNESS_SCHEMA
+ || witness.candidate_digest != candidate_digest
+ || witness.result != "pass"
+ })
+ {
+ return Err(ReproInstallError::InvalidWitness);
+ }
+ let expected_artifacts = [
+ candidate_artifacts,
+ candidate_artifacts,
+ predecessor_artifacts,
+ predecessor_artifacts,
+ candidate_artifacts,
+ candidate_artifacts,
+ predecessor_artifacts,
+ predecessor_artifacts,
+ ];
+ if witnesses
+ .iter()
+ .zip(expected_artifacts)
+ .any(|(witness, expected)| witness.artifact_set_sha256 != expected)
+ || witnesses[0].before_state_sha256 != EMPTY_STATE
+ || witnesses[1].before_state_sha256 != witnesses[0].after_state_sha256
+ || witnesses[1].after_state_sha256 != witnesses[0].after_state_sha256
+ || witnesses[2].before_state_sha256 != EMPTY_STATE
+ || witnesses[3].before_state_sha256 != witnesses[2].after_state_sha256
+ || witnesses[3].after_state_sha256 != witnesses[2].after_state_sha256
+ || witnesses[4].before_state_sha256 != witnesses[3].after_state_sha256
+ || witnesses[5].before_state_sha256 != witnesses[4].after_state_sha256
+ || witnesses[5].after_state_sha256 != witnesses[4].after_state_sha256
+ || witnesses[6].before_state_sha256 != witnesses[5].after_state_sha256
+ || witnesses[7].before_state_sha256 != witnesses[6].after_state_sha256
+ || witnesses[7].after_state_sha256 != witnesses[6].after_state_sha256
+ {
+ return Err(ReproInstallError::InvalidWitness);
+ }
+ Ok(())
+}
+
+fn artifact_inventory(root: &Path) -> Result<ArtifactInventory, ReproInstallError> {
+ let root = canonical_directory(root, ReproInstallError::InvalidArtifacts)?;
+ let mut entries = Vec::new();
+ let mut total_bytes = 0_u64;
+ for entry in walkdir::WalkDir::new(&root).follow_links(false) {
+ let entry = entry.map_err(|_| ReproInstallError::InvalidArtifacts)?;
+ if entry.path() == root {
+ continue;
+ }
+ let metadata =
+ fs::symlink_metadata(entry.path()).map_err(|_| ReproInstallError::InvalidArtifacts)?;
+ if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ if metadata.is_dir() {
+ continue;
+ }
+ if metadata.len() > MAX_ARTIFACT_FILE_BYTES || entries.len() == MAX_ARTIFACT_FILES {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ total_bytes = total_bytes
+ .checked_add(metadata.len())
+ .filter(|total| *total <= MAX_ARTIFACT_TOTAL_BYTES)
+ .ok_or(ReproInstallError::InvalidArtifacts)?;
+ let relative = entry
+ .path()
+ .strip_prefix(&root)
+ .map_err(|_| ReproInstallError::InvalidArtifacts)?;
+ let path = portable_relative_path(relative)?;
+ entries.push(ArtifactEntry {
+ path,
+ mode: file_mode(&metadata),
+ size_bytes: metadata.len(),
+ sha256: sha256_reader(
+ File::open(entry.path()).map_err(|_| ReproInstallError::InvalidArtifacts)?,
+ )?,
+ });
+ }
+ if entries.is_empty() {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
+ if entries.windows(2).any(|pair| pair[0].path == pair[1].path) {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ let sha256 = sha256_bytes(
+ &serde_json::to_vec(&entries).map_err(|_| ReproInstallError::InvalidArtifacts)?,
+ );
+ Ok(ArtifactInventory {
+ entries,
+ sha256,
+ total_bytes,
+ })
+}
+
+fn compare_inventories(
+ first: &ArtifactInventory,
+ second: &ArtifactInventory,
+) -> Vec<ArtifactDifference> {
+ let first = first
+ .entries
+ .iter()
+ .map(|entry| (entry.path.as_str(), entry))
+ .collect::<BTreeMap<_, _>>();
+ let second = second
+ .entries
+ .iter()
+ .map(|entry| (entry.path.as_str(), entry))
+ .collect::<BTreeMap<_, _>>();
+ first
+ .keys()
+ .chain(second.keys())
+ .copied()
+ .collect::<BTreeSet<_>>()
+ .into_iter()
+ .filter_map(|path| {
+ let left = first.get(path).copied();
+ let right = second.get(path).copied();
+ (left != right).then(|| ArtifactDifference {
+ path: path.to_owned(),
+ first: left.cloned(),
+ second: right.cloned(),
+ })
+ })
+ .take(MAX_DIFF_ENTRIES)
+ .collect()
+}
+
+fn build_result(
+ id: &str,
+ checkout_id: &str,
+ store_id: &str,
+ inventory: &ArtifactInventory,
+) -> Value {
+ json!({
+ "id": id,
+ "checkout_id": checkout_id,
+ "store_id": store_id,
+ "artifact_inventory_sha256": inventory.sha256,
+ "artifact_file_count": inventory.entries.len(),
+ "artifact_total_bytes": inventory.total_bytes,
+ "result": "pass"
+ })
+}
+
+fn failure_result(
+ plan: &Plan,
+ first: &ArtifactInventory,
+ second: &ArtifactInventory,
+ differences: &[ArtifactDifference],
+) -> Value {
+ json!({
+ "schema": RESULT_SCHEMA,
+ "candidate_digest": plan.candidate_digest,
+ "target": plan.target,
+ "source_revision": plan.source_revision,
+ "source_tree": plan.source_tree,
+ "root_preimage_revision": plan.root_preimage_revision,
+ "source_date_epoch": plan.source_date_epoch,
+ "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
+ "build": [
+ build_result("build-1", "checkout-1", "store-1", first),
+ build_result("build-2", "checkout-2", "store-2", second)
+ ],
+ "reproducibility": {
+ "first_inventory_sha256": first.sha256,
+ "second_inventory_sha256": second.sha256,
+ "difference": differences,
+ "result": "fail"
+ },
+ "predecessor_artifact_set_sha256": "not_evaluated",
+ "install_phase": [],
+ "result": "fail"
+ })
+}
+
+fn write_result(path: &Path, value: &Value) -> Result<(), ReproInstallError> {
+ let bytes = canonical_json_line(value).map_err(|_| ReproInstallError::InvalidOutput)?;
+ if bytes.len() > MAX_RESULT_BYTES {
+ return Err(ReproInstallError::InvalidOutput);
+ }
+ let parent = path.parent().ok_or(ReproInstallError::InvalidOutput)?;
+ let canonical_parent = parent
+ .canonicalize()
+ .map_err(|_| ReproInstallError::InvalidOutput)?;
+ if canonical_parent != parent || path.exists() || path.is_symlink() {
+ return Err(ReproInstallError::InvalidOutput);
+ }
+ #[cfg(unix)]
+ let mut output = {
+ use std::os::unix::fs::OpenOptionsExt;
+ OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(path)
+ .map_err(|_| ReproInstallError::InvalidOutput)?
+ };
+ #[cfg(not(unix))]
+ let mut output = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ .map_err(|_| ReproInstallError::InvalidOutput)?;
+ output
+ .write_all(&bytes)
+ .and_then(|()| output.sync_all())
+ .map_err(|_| ReproInstallError::InvalidOutput)
+}
+
+fn validate_output_target(path: &Path) -> Result<(), ReproInstallError> {
+ if !path.is_absolute() || path.exists() || path.is_symlink() {
+ return Err(ReproInstallError::InvalidOutput);
+ }
+ let parent = path.parent().ok_or(ReproInstallError::InvalidOutput)?;
+ let canonical = parent
+ .canonicalize()
+ .map_err(|_| ReproInstallError::InvalidOutput)?;
+ if canonical != parent {
+ return Err(ReproInstallError::InvalidOutput);
+ }
+ Ok(())
+}
+
+fn validate_executable(path: &Path, expected_sha256: &str) -> Result<(), ReproInstallError> {
+ if !path.is_absolute() || !valid_hex(expected_sha256, 64) {
+ return Err(ReproInstallError::InvalidTool);
+ }
+ let metadata = fs::symlink_metadata(path).map_err(|_| ReproInstallError::InvalidTool)?;
+ if !metadata.is_file() || metadata.file_type().is_symlink() || !executable_mode(&metadata) {
+ return Err(ReproInstallError::InvalidTool);
+ }
+ let observed = sha256_reader(File::open(path).map_err(|_| ReproInstallError::InvalidTool)?)
+ .map_err(|_| ReproInstallError::InvalidTool)?;
+ if observed != expected_sha256 {
+ return Err(ReproInstallError::InvalidTool);
+ }
+ Ok(())
+}
+
+fn read_regular_bounded(
+ path: &Path,
+ maximum: u64,
+ error: ReproInstallError,
+) -> Result<Vec<u8>, ReproInstallError> {
+ let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
+ if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > maximum {
+ return Err(error);
+ }
+ fs::read(path).map_err(|_| error)
+}
+
+fn canonical_directory(
+ path: &Path,
+ error: ReproInstallError,
+) -> Result<PathBuf, ReproInstallError> {
+ if !path.is_absolute() {
+ return Err(error);
+ }
+ let canonical = path.canonicalize().map_err(|_| error)?;
+ let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
+ if canonical != path || !metadata.is_dir() || metadata.file_type().is_symlink() {
+ return Err(error);
+ }
+ Ok(canonical)
+}
+
+fn create_owned_directory(path: &Path) -> Result<PathBuf, ReproInstallError> {
+ fs::create_dir(path).map_err(|_| ReproInstallError::InvalidOutput)?;
+ path.canonicalize()
+ .map_err(|_| ReproInstallError::InvalidOutput)
+}
+
+fn require_empty_directory(path: &Path, error: ReproInstallError) -> Result<(), ReproInstallError> {
+ let mut entries = fs::read_dir(path).map_err(|_| error)?;
+ if entries.next().transpose().map_err(|_| error)?.is_some() {
+ return Err(error);
+ }
+ Ok(())
+}
+
+fn require_distinct(
+ left: &Path,
+ right: &Path,
+ error: ReproInstallError,
+) -> Result<(), ReproInstallError> {
+ if left == right {
+ return Err(error);
+ }
+ let left_metadata = fs::metadata(left).map_err(|_| error)?;
+ let right_metadata = fs::metadata(right).map_err(|_| error)?;
+ if same_file(&left_metadata, &right_metadata) {
+ return Err(error);
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
+ use std::os::unix::fs::MetadataExt;
+ left.dev() == right.dev() && left.ino() == right.ino()
+}
+
+#[cfg(not(unix))]
+fn same_file(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
+ false
+}
+
+fn run_process(
+ program: &Path,
+ arguments: &[OsString],
+ current_dir: &Path,
+ environment: ReplacementEnvironment,
+ deadline_seconds: u64,
+ error: ReproInstallError,
+) -> Result<ProcessOutput, ReproInstallError> {
+ let mut request = ProcessRequest::new(program.as_os_str())
+ .current_dir(current_dir)
+ .environment(environment)
+ .deadline(Duration::from_secs(deadline_seconds))
+ .output_limits(MAX_PROCESS_STREAM_BYTES, MAX_PROCESS_STREAM_BYTES);
+ for argument in arguments {
+ request = request.arg(argument);
+ }
+ let output = bounded_process::run(&request).map_err(|_| error)?;
+ if !output.status().success() {
+ return Err(error);
+ }
+ Ok(output)
+}
+
+fn git_output<const N: usize>(
+ git: &Path,
+ current_dir: &Path,
+ home: &Path,
+ arguments: [&str; N],
+) -> Result<ProcessOutput, ReproInstallError> {
+ let arguments = arguments.map(OsString::from);
+ run_process(
+ git,
+ &arguments,
+ current_dir,
+ git_environment(home)?,
+ 60,
+ ReproInstallError::InvalidSource,
+ )
+}
+
+fn git_line<const N: usize>(
+ git: &Path,
+ current_dir: &Path,
+ home: &Path,
+ arguments: [&str; N],
+) -> Result<String, ReproInstallError> {
+ let output = git_output(git, current_dir, home, arguments)?;
+ let text =
+ std::str::from_utf8(output.stdout()).map_err(|_| ReproInstallError::InvalidSource)?;
+ let line = text
+ .strip_suffix('\n')
+ .ok_or(ReproInstallError::InvalidSource)?;
+ if line.is_empty() || line.contains(['\r', '\n']) {
+ return Err(ReproInstallError::InvalidSource);
+ }
+ Ok(line.to_owned())
+}
+
+fn git_environment(home: &Path) -> Result<ReplacementEnvironment, ReproInstallError> {
+ let mut environment = ReplacementEnvironment::default();
+ insert_environment(&mut environment, "HOME", home.as_os_str())?;
+ insert_environment(&mut environment, "LC_ALL", "C")?;
+ insert_environment(&mut environment, "TZ", "UTC")?;
+ insert_environment(&mut environment, "GIT_CONFIG_NOSYSTEM", "1")?;
+ Ok(environment)
+}
+
+fn harness_environment(
+ home: &Path,
+ plan: &Plan,
+ store: Option<&Path>,
+) -> Result<ReplacementEnvironment, ReproInstallError> {
+ let mut environment = ReplacementEnvironment::default();
+ insert_environment(&mut environment, "HOME", home.as_os_str())?;
+ insert_environment(&mut environment, "LC_ALL", "C")?;
+ insert_environment(&mut environment, "TZ", "UTC")?;
+ insert_environment(
+ &mut environment,
+ "SOURCE_DATE_EPOCH",
+ plan.source_date_epoch.to_string(),
+ )?;
+ insert_environment(
+ &mut environment,
+ "RSHR_CANDIDATE_DIGEST",
+ &plan.candidate_digest,
+ )?;
+ if let Some(store) = store {
+ insert_environment(&mut environment, "RSHR_BUILD_STORE", store.as_os_str())?;
+ }
+ Ok(environment)
+}
+
+fn insert_environment(
+ environment: &mut ReplacementEnvironment,
+ name: &str,
+ value: impl Into<OsString>,
+) -> Result<(), ReproInstallError> {
+ environment
+ .insert(name, value)
+ .map_err(|_| ReproInstallError::InvalidPlan)
+}
+
+fn resolve_arguments(
+ template: &[String],
+ values: &BTreeMap<&str, OsString>,
+) -> Result<Vec<OsString>, ReproInstallError> {
+ template
+ .iter()
+ .map(|argument| {
+ if argument.contains('{') || argument.contains('}') {
+ values
+ .get(argument.as_str())
+ .cloned()
+ .ok_or(ReproInstallError::InvalidPlan)
+ } else {
+ Ok(OsString::from(argument))
+ }
+ })
+ .collect()
+}
+
+fn portable_relative_path(path: &Path) -> Result<String, ReproInstallError> {
+ let mut parts = Vec::new();
+ for component in path.components() {
+ let Component::Normal(part) = component else {
+ return Err(ReproInstallError::InvalidArtifacts);
+ };
+ let part = part.to_str().ok_or(ReproInstallError::InvalidArtifacts)?;
+ if part.is_empty()
+ || part == "."
+ || part == ".."
+ || part.contains(['/', '\\', '\0', '\r', '\n'])
+ {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ parts.push(part);
+ }
+ if parts.is_empty() {
+ return Err(ReproInstallError::InvalidArtifacts);
+ }
+ Ok(parts.join("/"))
+}
+
+fn sha256_reader(mut reader: impl Read) -> Result<String, ReproInstallError> {
+ let mut digest = Sha256::new();
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let count = reader
+ .read(&mut buffer)
+ .map_err(|_| ReproInstallError::InvalidArtifacts)?;
+ if count == 0 {
+ break;
+ }
+ digest.update(&buffer[..count]);
+ }
+ Ok(hex::encode(digest.finalize()))
+}
+
+fn sha256_bytes(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+fn canonical_json_line(value: &Value) -> Result<Vec<u8>, serde_json::Error> {
+ let mut bytes = serde_json::to_vec(value)?;
+ bytes.push(b'\n');
+ Ok(bytes)
+}
+
+fn valid_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn valid_state(value: &str) -> bool {
+ value == EMPTY_STATE || valid_hex(value, 64)
+}
+
+fn os_string(path: &Path) -> OsString {
+ path.as_os_str().to_owned()
+}
+
+#[cfg(unix)]
+fn file_mode(metadata: &fs::Metadata) -> u32 {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o777
+}
+
+#[cfg(not(unix))]
+fn file_mode(metadata: &fs::Metadata) -> u32 {
+ if metadata.permissions().readonly() {
+ 0o444
+ } else {
+ 0o666
+ }
+}
+
+#[cfg(unix)]
+fn executable_mode(metadata: &fs::Metadata) -> bool {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o111 != 0
+}
+
+#[cfg(not(unix))]
+fn executable_mode(_metadata: &fs::Metadata) -> bool {
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::process::Command;
+ use tempfile::TempDir;
+
+ fn sample_plan_value() -> Value {
+ json!({
+ "schema": PLAN_SCHEMA,
+ "candidate_digest": "a".repeat(64),
+ "target": "aarch64-apple-darwin",
+ "source_revision": "b".repeat(40),
+ "source_tree": "c".repeat(40),
+ "root_preimage_revision": "d".repeat(40),
+ "source_date_epoch": 1_700_000_000_u64,
+ "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
+ "git_executable_sha256": "e".repeat(64),
+ "adapter_executable_sha256": "f".repeat(64),
+ "build_argv": BUILD_PLACEHOLDERS,
+ "phase": PHASE_IDS.map(|id| json!({"id": id, "argv": PHASE_PLACEHOLDERS}))
+ })
+ }
+
+ fn sample_plan() -> Plan {
+ serde_json::from_value(sample_plan_value()).expect("sample plan")
+ }
+
+ fn witness(phase: &str, artifacts: &str, before: &str, after: &str) -> PhaseWitness {
+ PhaseWitness {
+ schema: WITNESS_SCHEMA.to_owned(),
+ phase: phase.to_owned(),
+ candidate_digest: "a".repeat(64),
+ artifact_set_sha256: artifacts.to_owned(),
+ before_state_sha256: before.to_owned(),
+ after_state_sha256: after.to_owned(),
+ result: "pass".to_owned(),
+ }
+ }
+
+ fn valid_witnesses() -> Vec<PhaseWitness> {
+ let candidate = "1".repeat(64);
+ let predecessor = "2".repeat(64);
+ let fresh = "3".repeat(64);
+ let installed = "4".repeat(64);
+ let upgraded = "5".repeat(64);
+ let rolled_back = "6".repeat(64);
+ vec![
+ witness(PHASE_IDS[0], &candidate, EMPTY_STATE, &fresh),
+ witness(PHASE_IDS[1], &candidate, &fresh, &fresh),
+ witness(PHASE_IDS[2], &predecessor, EMPTY_STATE, &installed),
+ witness(PHASE_IDS[3], &predecessor, &installed, &installed),
+ witness(PHASE_IDS[4], &candidate, &installed, &upgraded),
+ witness(PHASE_IDS[5], &candidate, &upgraded, &upgraded),
+ witness(PHASE_IDS[6], &predecessor, &upgraded, &rolled_back),
+ witness(PHASE_IDS[7], &predecessor, &rolled_back, &rolled_back),
+ ]
+ }
+
+ fn write_file(path: &Path, bytes: &[u8]) {
+ fs::create_dir_all(path.parent().expect("parent")).expect("directory");
+ fs::write(path, bytes).expect("file");
+ }
+
+ fn program_path(name: &str) -> PathBuf {
+ std::env::split_paths(&std::env::var_os("PATH").expect("PATH"))
+ .map(|directory| directory.join(name))
+ .find(|candidate| candidate.is_file())
+ .expect("program on PATH")
+ .canonicalize()
+ .expect("canonical program")
+ }
+
+ fn fixture_git(root: &Path, arguments: &[&str]) -> String {
+ let output = Command::new(program_path("git"))
+ .args(arguments)
+ .current_dir(root)
+ .env("GIT_CONFIG_NOSYSTEM", "1")
+ .output()
+ .expect("fixture git");
+ assert!(
+ output.status.success(),
+ "fixture git failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ String::from_utf8(output.stdout)
+ .expect("fixture git output")
+ .trim()
+ .to_owned()
+ }
+
+ fn create_repository(path: &Path, filename: &str) {
+ fs::create_dir(path).expect("repository directory");
+ fixture_git(path, &["init", "--quiet", "--initial-branch=master"]);
+ fixture_git(path, &["config", "user.name", "Radroots Test"]);
+ fixture_git(path, &["config", "user.email", "test@radroots.invalid"]);
+ write_file(&path.join(filename), b"tracked\n");
+ fixture_git(path, &["add", filename]);
+ fixture_git(path, &["commit", "--quiet", "-m", "fixture"]);
+ }
+
+ #[cfg(unix)]
+ fn write_fixture_adapter(path: &Path) {
+ use std::os::unix::fs::PermissionsExt;
+
+ let script = r#"#!/bin/sh
+set -eu
+if [ "$1" = "build" ]; then
+ printf '%s\n' 'exact candidate artifact' > "$4/release.bin"
+ exit 0
+fi
+if [ "$1" != "phase" ]; then
+ exit 2
+fi
+phase="$2"
+install_root="$3"
+if [ -f "$install_root/state" ]; then
+ IFS= read -r before < "$install_root/state"
+else
+ before=empty
+fi
+case "$phase" in
+ fresh_install_candidate) after=3333333333333333333333333333333333333333333333333333333333333333 ;;
+ fresh_health_candidate) after="$before" ;;
+ install_predecessor) after=4444444444444444444444444444444444444444444444444444444444444444 ;;
+ pre_upgrade_health) after="$before" ;;
+ upgrade_candidate) after=5555555555555555555555555555555555555555555555555555555555555555 ;;
+ post_upgrade_health) after="$before" ;;
+ rollback_predecessor) after=6666666666666666666666666666666666666666666666666666666666666666 ;;
+ post_rollback_health) after="$before" ;;
+ *) exit 2 ;;
+esac
+printf '%s\n' "$after" > "$install_root/state"
+printf '{"after_state_sha256":"%s","artifact_set_sha256":"%s","before_state_sha256":"%s","candidate_digest":"%s","phase":"%s","result":"pass","schema":"radroots.services-hardening.repro-install-phase-witness.v1"}\n' "$after" "$5" "$before" "$7" "$phase"
+"#;
+ fs::write(path, script).expect("adapter");
+ fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("adapter mode");
+ }
+
+ #[test]
+ fn decision_contract_is_exact() {
+ validate_contract_inner(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
+ .expect("decision contract");
+ assert_eq!(
+ [
+ ReproInstallError::InvalidContract,
+ ReproInstallError::InvalidPlan,
+ ReproInstallError::InvalidSource,
+ ReproInstallError::DirtySource,
+ ReproInstallError::InvalidTool,
+ ReproInstallError::CheckoutFailure,
+ ReproInstallError::BuildFailure,
+ ReproInstallError::InvalidArtifacts,
+ ReproInstallError::ReproducibilityMismatch,
+ ReproInstallError::InstallFailure,
+ ReproInstallError::InvalidWitness,
+ ReproInstallError::InvalidOutput,
+ ]
+ .map(ReproInstallError::code),
+ [
+ "invalid_contract",
+ "invalid_plan",
+ "invalid_source",
+ "dirty_source",
+ "invalid_tool",
+ "checkout_failure",
+ "build_failure",
+ "invalid_artifacts",
+ "reproducibility_mismatch",
+ "install_failure",
+ "invalid_witness",
+ "invalid_output",
+ ]
+ );
+ }
+
+ #[test]
+ fn plan_requires_closed_normalization_tools_and_ordered_phases() {
+ validate_plan(&sample_plan()).expect("valid plan");
+ let mut open = sample_plan_value();
+ open["normalization"]["excluded_paths"] = json!(["build-id"]);
+ assert_eq!(
+ validate_plan(&serde_json::from_value(open).expect("open plan")),
+ Err(ReproInstallError::InvalidPlan)
+ );
+ let mut reordered = sample_plan_value();
+ reordered["phase"]
+ .as_array_mut()
+ .expect("phases")
+ .swap(0, 1);
+ assert_eq!(
+ validate_plan(&serde_json::from_value(reordered).expect("reordered plan")),
+ Err(ReproInstallError::InvalidPlan)
+ );
+ let mut missing_placeholder = sample_plan_value();
+ missing_placeholder["build_argv"] = json!(&BUILD_PLACEHOLDERS[1..]);
+ assert_eq!(
+ validate_plan(
+ &serde_json::from_value(missing_placeholder).expect("missing placeholder")
+ ),
+ Err(ReproInstallError::InvalidPlan)
+ );
+ }
+
+ #[test]
+ fn canonical_plan_and_witness_reject_noncanonical_bytes() {
+ let temporary = TempDir::new().expect("temporary");
+ let plan_path = temporary.path().join("plan.json");
+ let canonical = canonical_json_line(&sample_plan_value()).expect("canonical plan");
+ fs::write(&plan_path, &canonical).expect("plan");
+ load_plan(&plan_path).expect("canonical accepted");
+ let mut pretty = serde_json::to_vec_pretty(&sample_plan_value()).expect("pretty");
+ pretty.push(b'\n');
+ fs::write(&plan_path, pretty).expect("pretty plan");
+ assert!(matches!(
+ load_plan(&plan_path),
+ Err(ReproInstallError::InvalidPlan)
+ ));
+
+ let value = serde_json::to_value(&valid_witnesses()[0]).expect("witness value");
+ let canonical = canonical_json_line(&value).expect("canonical witness");
+ assert_eq!(canonical.last(), Some(&b'\n'));
+ assert_ne!(
+ serde_json::to_vec_pretty(&value).expect("pretty witness"),
+ canonical
+ );
+ }
+
+ #[test]
+ fn exact_artifact_inventory_reports_path_mode_size_and_digest_differences() {
+ let temporary = TempDir::new().expect("temporary");
+ let first_root = temporary.path().join("first");
+ let second_root = temporary.path().join("second");
+ write_file(&first_root.join("bin/service"), b"same");
+ write_file(&second_root.join("bin/service"), b"same");
+ let first = artifact_inventory(&first_root.canonicalize().expect("first root"))
+ .expect("first inventory");
+ let second = artifact_inventory(&second_root.canonicalize().expect("second root"))
+ .expect("second inventory");
+ assert!(compare_inventories(&first, &second).is_empty());
+
+ write_file(&second_root.join("bin/service"), b"changed");
+ write_file(&second_root.join("extra"), b"extra");
+ let second = artifact_inventory(&second_root.canonicalize().expect("second root"))
+ .expect("changed inventory");
+ let differences = compare_inventories(&first, &second);
+ assert_eq!(
+ differences
+ .iter()
+ .map(|difference| difference.path.as_str())
+ .collect::<Vec<_>>(),
+ ["bin/service", "extra"]
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn artifact_inventory_rejects_symlinks_and_binds_mode() {
+ use std::os::unix::fs::{PermissionsExt, symlink};
+
+ let temporary = TempDir::new().expect("temporary");
+ let root = temporary.path().join("artifacts");
+ write_file(&root.join("service"), b"service");
+ fs::set_permissions(root.join("service"), fs::Permissions::from_mode(0o755)).expect("mode");
+ let executable =
+ artifact_inventory(&root.canonicalize().expect("root")).expect("executable inventory");
+ assert_eq!(executable.entries[0].mode, 0o755);
+ symlink("service", root.join("linked")).expect("symlink");
+ assert!(matches!(
+ artifact_inventory(&root.canonicalize().expect("root")),
+ Err(ReproInstallError::InvalidArtifacts)
+ ));
+ }
+
+ #[test]
+ fn install_upgrade_and_rollback_witnesses_form_exact_chains() {
+ let candidate = "1".repeat(64);
+ let predecessor = "2".repeat(64);
+ let witnesses = valid_witnesses();
+ validate_phase_chain(&witnesses, &"a".repeat(64), &candidate, &predecessor)
+ .expect("valid phase chain");
+
+ let mut mutating_health = valid_witnesses();
+ mutating_health[5].after_state_sha256 = "7".repeat(64);
+ assert_eq!(
+ validate_phase_chain(&mutating_health, &"a".repeat(64), &candidate, &predecessor),
+ Err(ReproInstallError::InvalidWitness)
+ );
+ let mut broken_rollback = valid_witnesses();
+ broken_rollback[6].before_state_sha256 = "8".repeat(64);
+ assert_eq!(
+ validate_phase_chain(&broken_rollback, &"a".repeat(64), &candidate, &predecessor),
+ Err(ReproInstallError::InvalidWitness)
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn exact_tool_bytes_and_create_new_output_fail_closed() {
+ use std::os::unix::fs::{PermissionsExt, symlink};
+
+ let temporary = TempDir::new().expect("temporary");
+ let root = temporary.path().canonicalize().expect("root");
+ let tool = root.join("tool");
+ fs::write(&tool, b"tool").expect("tool");
+ fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("mode");
+ validate_executable(&tool, &sha256_bytes(b"tool")).expect("valid tool");
+ assert_eq!(
+ validate_executable(&tool, &sha256_bytes(b"replacement")),
+ Err(ReproInstallError::InvalidTool)
+ );
+ let link = root.join("tool-link");
+ symlink(&tool, &link).expect("tool symlink");
+ assert_eq!(
+ validate_executable(&link, &sha256_bytes(b"tool")),
+ Err(ReproInstallError::InvalidTool)
+ );
+
+ let output = root.join("result.json");
+ write_result(&output, &json!({"result": "pass"})).expect("first result");
+ assert_eq!(
+ write_result(&output, &json!({"result": "pass"})),
+ Err(ReproInstallError::InvalidOutput)
+ );
+ }
+
+ #[test]
+ fn source_epoch_and_distinct_store_invariants_are_exact() {
+ let temporary = TempDir::new().expect("temporary");
+ let first = temporary.path().join("first");
+ let second = temporary.path().join("second");
+ fs::create_dir(&first).expect("first");
+ fs::create_dir(&second).expect("second");
+ require_distinct(&first, &second, ReproInstallError::BuildFailure).expect("distinct roots");
+ assert_eq!(
+ require_distinct(&first, &first, ReproInstallError::BuildFailure),
+ Err(ReproInstallError::BuildFailure)
+ );
+ let plan = sample_plan();
+ assert_eq!(plan.source_date_epoch, 1_700_000_000);
+ assert_ne!(plan.root_preimage_revision, plan.source_revision);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn full_harness_uses_two_clones_root_epoch_and_all_install_phases() {
+ let temporary = TempDir::new().expect("temporary");
+ let fixture_root = temporary.path().canonicalize().expect("fixture root");
+ let source = fixture_root.join("source");
+ let root_preimage = fixture_root.join("root-preimage");
+ create_repository(&source, "source.txt");
+ create_repository(&root_preimage, "root.txt");
+ let source_revision = fixture_git(&source, &["rev-parse", "HEAD"]);
+ let source_tree = fixture_git(&source, &["rev-parse", "HEAD^{tree}"]);
+ let root_revision = fixture_git(&root_preimage, &["rev-parse", "HEAD"]);
+ let source_date_epoch = fixture_git(
+ &root_preimage,
+ &["show", "-s", "--format=%ct", &root_revision],
+ )
+ .parse::<u64>()
+ .expect("root epoch");
+ let predecessor = fixture_root.join("predecessor");
+ write_file(
+ &predecessor.join("release.bin"),
+ b"exact predecessor artifact\n",
+ );
+ let predecessor = predecessor.canonicalize().expect("predecessor");
+ let git = program_path("git");
+ let adapter = fixture_root.join("adapter");
+ write_fixture_adapter(&adapter);
+
+ let mut plan_value = sample_plan_value();
+ plan_value["source_revision"] = json!(source_revision);
+ plan_value["source_tree"] = json!(source_tree);
+ plan_value["root_preimage_revision"] = json!(root_revision);
+ plan_value["source_date_epoch"] = json!(source_date_epoch);
+ plan_value["git_executable_sha256"] =
+ json!(sha256_reader(File::open(&git).expect("git")).expect("git hash"));
+ plan_value["adapter_executable_sha256"] =
+ json!(sha256_bytes(&fs::read(&adapter).expect("adapter bytes")));
+ plan_value["build_argv"] = json!([
+ "build",
+ "{checkout}",
+ "{store}",
+ "{output}",
+ "{source_date_epoch}",
+ "{candidate_digest}",
+ "{target}"
+ ]);
+ plan_value["phase"] = json!(PHASE_IDS.map(|id| json!({
+ "id": id,
+ "argv": [
+ "phase",
+ "{phase}",
+ "{install_root}",
+ "{artifact_root}",
+ "{artifact_set_sha256}",
+ "{source_date_epoch}",
+ "{candidate_digest}",
+ "{target}"
+ ]
+ })));
+ let plan_path = fixture_root.join("plan.json");
+ fs::write(
+ &plan_path,
+ canonical_json_line(&plan_value).expect("plan bytes"),
+ )
+ .expect("plan");
+ let output = fixture_root.join("result.json");
+ let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ run_inner(
+ &workspace_root,
+ Arguments {
+ plan: &plan_path,
+ source_root: &source,
+ root_preimage_root: &root_preimage,
+ predecessor_artifact_root: &predecessor,
+ git_executable: &git,
+ adapter_executable: &adapter,
+ output: &output,
+ },
+ )
+ .expect("full harness");
+ let result: Value =
+ serde_json::from_slice(&fs::read(&output).expect("result")).expect("result JSON");
+ assert_eq!(result["result"], "pass");
+ assert_eq!(result["build"].as_array().expect("builds").len(), 2);
+ assert_eq!(
+ result["build"][0]["artifact_inventory_sha256"],
+ result["build"][1]["artifact_inventory_sha256"]
+ );
+ assert_eq!(
+ result["install_phase"].as_array().expect("phases").len(),
+ PHASE_IDS.len()
+ );
+
+ write_file(&source.join("untracked.txt"), b"dirty\n");
+ let dirty_output = fixture_root.join("dirty-result.json");
+ assert_eq!(
+ run_inner(
+ &workspace_root,
+ Arguments {
+ plan: &plan_path,
+ source_root: &source,
+ root_preimage_root: &root_preimage,
+ predecessor_artifact_root: &predecessor,
+ git_executable: &git,
+ adapter_executable: &adapter,
+ output: &dirty_output,
+ },
+ ),
+ Err(ReproInstallError::DirtySource)
+ );
+ }
+}