lib

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

service_repro_install.rs (68259B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 use std::ffi::OsString;
      3 use std::fs::{self, File, OpenOptions};
      4 use std::io::{Read, Write};
      5 use std::path::{Component, Path, PathBuf};
      6 use std::time::Duration;
      7 
      8 use serde::{Deserialize, Serialize};
      9 use serde_json::{Value, json};
     10 use sha2::{Digest as _, Sha256};
     11 
     12 use crate::bounded_process::{self, ProcessOutput, ProcessRequest, ReplacementEnvironment};
     13 
     14 const CONTRACT_RELATIVE: &str =
     15     "contracts/architecture/decisions/services_hardening_repro_install.v1.json";
     16 const PLAN_SCHEMA: &str = "radroots.services-hardening.repro-install-plan.v1";
     17 const RESULT_SCHEMA: &str = "radroots.services-hardening.repro-install-result.v1";
     18 const WITNESS_SCHEMA: &str = "radroots.services-hardening.repro-install-phase-witness.v1";
     19 const NORMALIZATION_KIND: &str = "identity_exact_bytes_v1";
     20 const MAX_PLAN_BYTES: u64 = 1024 * 1024;
     21 const MAX_RESULT_BYTES: usize = 4 * 1024 * 1024;
     22 const MAX_PROCESS_STREAM_BYTES: usize = 32 * 1024 * 1024;
     23 const MAX_PROCESS_DEADLINE_SECONDS: u64 = 3600;
     24 const MAX_ARTIFACT_FILES: usize = 65_536;
     25 const MAX_ARTIFACT_TOTAL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
     26 const MAX_ARTIFACT_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
     27 const MAX_DIFF_ENTRIES: usize = 256;
     28 const MAX_ARGV_TOKENS: usize = 128;
     29 const MAX_ARGV_TOKEN_BYTES: usize = 4096;
     30 const EMPTY_STATE: &str = "empty";
     31 const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"];
     32 const BUILD_PLACEHOLDERS: [&str; 6] = [
     33     "{checkout}",
     34     "{store}",
     35     "{output}",
     36     "{source_date_epoch}",
     37     "{candidate_digest}",
     38     "{target}",
     39 ];
     40 const PHASE_PLACEHOLDERS: [&str; 7] = [
     41     "{phase}",
     42     "{install_root}",
     43     "{artifact_root}",
     44     "{artifact_set_sha256}",
     45     "{source_date_epoch}",
     46     "{candidate_digest}",
     47     "{target}",
     48 ];
     49 const PHASE_IDS: [&str; 8] = [
     50     "fresh_install_candidate",
     51     "fresh_health_candidate",
     52     "install_predecessor",
     53     "pre_upgrade_health",
     54     "upgrade_candidate",
     55     "post_upgrade_health",
     56     "rollback_predecessor",
     57     "post_rollback_health",
     58 ];
     59 
     60 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     61 pub(crate) enum ReproInstallError {
     62     InvalidContract,
     63     InvalidPlan,
     64     InvalidSource,
     65     DirtySource,
     66     InvalidTool,
     67     CheckoutFailure,
     68     BuildFailure,
     69     InvalidArtifacts,
     70     ReproducibilityMismatch,
     71     InstallFailure,
     72     InvalidWitness,
     73     InvalidOutput,
     74 }
     75 
     76 impl ReproInstallError {
     77     fn code(self) -> &'static str {
     78         match self {
     79             Self::InvalidContract => "invalid_contract",
     80             Self::InvalidPlan => "invalid_plan",
     81             Self::InvalidSource => "invalid_source",
     82             Self::DirtySource => "dirty_source",
     83             Self::InvalidTool => "invalid_tool",
     84             Self::CheckoutFailure => "checkout_failure",
     85             Self::BuildFailure => "build_failure",
     86             Self::InvalidArtifacts => "invalid_artifacts",
     87             Self::ReproducibilityMismatch => "reproducibility_mismatch",
     88             Self::InstallFailure => "install_failure",
     89             Self::InvalidWitness => "invalid_witness",
     90             Self::InvalidOutput => "invalid_output",
     91         }
     92     }
     93 }
     94 
     95 impl std::fmt::Display for ReproInstallError {
     96     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     97         formatter.write_str(self.code())
     98     }
     99 }
    100 
    101 #[derive(Debug)]
    102 pub(crate) struct Arguments<'a> {
    103     pub(crate) plan: &'a Path,
    104     pub(crate) source_root: &'a Path,
    105     pub(crate) root_preimage_root: &'a Path,
    106     pub(crate) predecessor_artifact_root: &'a Path,
    107     pub(crate) git_executable: &'a Path,
    108     pub(crate) adapter_executable: &'a Path,
    109     pub(crate) output: &'a Path,
    110 }
    111 
    112 #[derive(Debug, Deserialize)]
    113 #[serde(deny_unknown_fields)]
    114 struct Plan {
    115     schema: String,
    116     candidate_digest: String,
    117     target: String,
    118     source_revision: String,
    119     source_tree: String,
    120     root_preimage_revision: String,
    121     source_date_epoch: u64,
    122     normalization: Normalization,
    123     git_executable_sha256: String,
    124     adapter_executable_sha256: String,
    125     build_argv: Vec<String>,
    126     phase: Vec<PhasePlan>,
    127 }
    128 
    129 #[derive(Debug, Deserialize)]
    130 #[serde(deny_unknown_fields)]
    131 struct Normalization {
    132     kind: String,
    133     excluded_paths: Vec<String>,
    134 }
    135 
    136 #[derive(Debug, Deserialize)]
    137 #[serde(deny_unknown_fields)]
    138 struct PhasePlan {
    139     id: String,
    140     argv: Vec<String>,
    141 }
    142 
    143 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
    144 #[serde(deny_unknown_fields)]
    145 struct PhaseWitness {
    146     schema: String,
    147     phase: String,
    148     candidate_digest: String,
    149     artifact_set_sha256: String,
    150     before_state_sha256: String,
    151     after_state_sha256: String,
    152     result: String,
    153 }
    154 
    155 #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
    156 struct ArtifactEntry {
    157     path: String,
    158     mode: u32,
    159     size_bytes: u64,
    160     sha256: String,
    161 }
    162 
    163 #[derive(Debug)]
    164 struct ArtifactInventory {
    165     entries: Vec<ArtifactEntry>,
    166     sha256: String,
    167     total_bytes: u64,
    168 }
    169 
    170 #[derive(Debug, Serialize)]
    171 struct ArtifactDifference {
    172     path: String,
    173     first: Option<ArtifactEntry>,
    174     second: Option<ArtifactEntry>,
    175 }
    176 
    177 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> {
    178     validate_contract_inner(workspace_root).map_err(|error| error.to_string())
    179 }
    180 
    181 fn validate_contract_inner(workspace_root: &Path) -> Result<(), ReproInstallError> {
    182     let bytes = read_regular_bounded(
    183         &workspace_root.join(CONTRACT_RELATIVE),
    184         MAX_PLAN_BYTES,
    185         ReproInstallError::InvalidContract,
    186     )?;
    187     let observed =
    188         serde_json::from_slice::<Value>(&bytes).map_err(|_| ReproInstallError::InvalidContract)?;
    189     if observed != expected_contract() {
    190         return Err(ReproInstallError::InvalidContract);
    191     }
    192     Ok(())
    193 }
    194 
    195 fn expected_contract() -> Value {
    196     json!({
    197         "schema": "radroots.services-hardening.repro-install-decisions.v1",
    198         "contract_version": 1,
    199         "decision_state": "active",
    200         "owner_step": 306,
    201         "command": "cargo xtask service-repro-install",
    202         "required_arguments": ["plan", "source_root", "root_preimage_root", "predecessor_artifact_root", "git_executable", "adapter_executable", "output"],
    203         "plan_schema": PLAN_SCHEMA,
    204         "result_schema": RESULT_SCHEMA,
    205         "phase_witness_schema": WITNESS_SCHEMA,
    206         "candidate_binding": "explicit_sha256_candidate_identity_digest",
    207         "source_binding": "exact_clean_git_revision_and_tree",
    208         "checkout_policy": "two_fresh_detached_no_local_no_checkout_clones",
    209         "checkout_count": 2,
    210         "store_policy": "one_distinct_empty_harness_owned_store_per_build",
    211         "source_date_epoch": "exact_root_preimage_commit_timestamp",
    212         "normalization": "identity_exact_bytes_v1_no_exclusions",
    213         "reproducibility_comparison": "exact_relative_path_mode_size_and_sha256_inventory",
    214         "tool_binding": "exact_regular_executable_bytes_sha256",
    215         "process_policy": "bounded_process_group_closed_stdin_replacement_environment",
    216         "result_write_policy": "create_new_regular_file_only_with_failure_diff_preserved",
    217         "install_roots": "distinct_fresh_and_lifecycle_harness_owned_roots",
    218         "phase_inventory": PHASE_IDS,
    219         "phase_state_policy": "canonical_witnesses_form_two_closed_state_chains_and_health_is_nonmutating",
    220         "maximums": {
    221             "plan_bytes": MAX_PLAN_BYTES,
    222             "result_bytes": MAX_RESULT_BYTES,
    223             "process_stream_bytes": MAX_PROCESS_STREAM_BYTES,
    224             "process_deadline_seconds": MAX_PROCESS_DEADLINE_SECONDS,
    225             "artifact_files": MAX_ARTIFACT_FILES,
    226             "artifact_total_bytes": MAX_ARTIFACT_TOTAL_BYTES,
    227             "artifact_file_bytes": MAX_ARTIFACT_FILE_BYTES,
    228             "diff_entries": MAX_DIFF_ENTRIES,
    229             "argv_tokens": MAX_ARGV_TOKENS,
    230             "argv_token_bytes": MAX_ARGV_TOKEN_BYTES
    231         },
    232         "required_negative_vectors": [
    233             "dirty_source", "wrong_source_tree", "wrong_root_preimage_epoch",
    234             "reused_checkout_or_store", "artifact_path_mode_size_or_digest_mismatch",
    235             "open_normalization", "missing_or_reordered_phase", "noncanonical_witness",
    236             "mutating_health_witness", "broken_upgrade_or_rollback_chain",
    237             "replaced_tool_executable", "preexisting_output"
    238         ],
    239         "negative_error_codes": [
    240             "invalid_contract", "invalid_plan", "invalid_source", "dirty_source",
    241             "invalid_tool", "checkout_failure", "build_failure", "invalid_artifacts",
    242             "reproducibility_mismatch", "install_failure", "invalid_witness", "invalid_output"
    243         ]
    244     })
    245 }
    246 
    247 pub(crate) fn run(workspace_root: &Path, arguments: Arguments<'_>) -> Result<(), String> {
    248     run_inner(workspace_root, arguments).map_err(|error| error.to_string())
    249 }
    250 
    251 fn run_inner(workspace_root: &Path, arguments: Arguments<'_>) -> Result<(), ReproInstallError> {
    252     validate_contract_inner(workspace_root)?;
    253     validate_output_target(arguments.output)?;
    254     let plan = load_plan(arguments.plan)?;
    255     validate_plan(&plan)?;
    256     validate_executable(arguments.git_executable, &plan.git_executable_sha256)?;
    257     validate_executable(
    258         arguments.adapter_executable,
    259         &plan.adapter_executable_sha256,
    260     )?;
    261 
    262     let source_root = canonical_directory(arguments.source_root, ReproInstallError::InvalidSource)?;
    263     let root_preimage_root = canonical_directory(
    264         arguments.root_preimage_root,
    265         ReproInstallError::InvalidSource,
    266     )?;
    267     let predecessor_root = canonical_directory(
    268         arguments.predecessor_artifact_root,
    269         ReproInstallError::InvalidArtifacts,
    270     )?;
    271     let temporary = tempfile::Builder::new()
    272         .prefix("radroots-repro-install-")
    273         .tempdir()
    274         .map_err(|_| ReproInstallError::InvalidOutput)?;
    275     let harness_root = temporary
    276         .path()
    277         .canonicalize()
    278         .map_err(|_| ReproInstallError::InvalidOutput)?;
    279     let home = create_owned_directory(&harness_root.join("home"))?;
    280 
    281     verify_source(
    282         arguments.git_executable,
    283         &source_root,
    284         &home,
    285         &plan.source_revision,
    286         &plan.source_tree,
    287     )?;
    288     verify_root_preimage_epoch(
    289         arguments.git_executable,
    290         &root_preimage_root,
    291         &home,
    292         &plan.root_preimage_revision,
    293         plan.source_date_epoch,
    294     )?;
    295 
    296     let checkout_one = harness_root.join("checkout-1");
    297     let checkout_two = harness_root.join("checkout-2");
    298     create_checkout(
    299         arguments.git_executable,
    300         &source_root,
    301         &checkout_one,
    302         &home,
    303         &plan,
    304     )?;
    305     create_checkout(
    306         arguments.git_executable,
    307         &source_root,
    308         &checkout_two,
    309         &home,
    310         &plan,
    311     )?;
    312     require_distinct(
    313         &checkout_one,
    314         &checkout_two,
    315         ReproInstallError::CheckoutFailure,
    316     )?;
    317 
    318     let store_one = create_owned_directory(&harness_root.join("store-1"))?;
    319     let store_two = create_owned_directory(&harness_root.join("store-2"))?;
    320     let output_one = create_owned_directory(&harness_root.join("output-1"))?;
    321     let output_two = create_owned_directory(&harness_root.join("output-2"))?;
    322     require_distinct(&store_one, &store_two, ReproInstallError::BuildFailure)?;
    323     require_distinct(&output_one, &output_two, ReproInstallError::BuildFailure)?;
    324     run_build(
    325         arguments.adapter_executable,
    326         &plan,
    327         &checkout_one,
    328         &store_one,
    329         &output_one,
    330         &home,
    331     )?;
    332     run_build(
    333         arguments.adapter_executable,
    334         &plan,
    335         &checkout_two,
    336         &store_two,
    337         &output_two,
    338         &home,
    339     )?;
    340     let first = artifact_inventory(&output_one)?;
    341     let second = artifact_inventory(&output_two)?;
    342     let differences = compare_inventories(&first, &second);
    343     if !differences.is_empty() {
    344         let result = failure_result(&plan, &first, &second, &differences);
    345         write_result(arguments.output, &result)?;
    346         return Err(ReproInstallError::ReproducibilityMismatch);
    347     }
    348 
    349     let predecessor = artifact_inventory(&predecessor_root)?;
    350     let fresh_root = create_owned_directory(&harness_root.join("install-fresh"))?;
    351     let lifecycle_root = create_owned_directory(&harness_root.join("install-lifecycle"))?;
    352     require_distinct(
    353         &fresh_root,
    354         &lifecycle_root,
    355         ReproInstallError::InstallFailure,
    356     )?;
    357     let witnesses = run_install_phases(
    358         arguments.adapter_executable,
    359         &plan,
    360         &output_one,
    361         &first.sha256,
    362         &predecessor_root,
    363         &predecessor.sha256,
    364         &fresh_root,
    365         &lifecycle_root,
    366         &home,
    367     )?;
    368     validate_phase_chain(
    369         &witnesses,
    370         &plan.candidate_digest,
    371         &first.sha256,
    372         &predecessor.sha256,
    373     )?;
    374 
    375     let result = json!({
    376         "schema": RESULT_SCHEMA,
    377         "candidate_digest": plan.candidate_digest,
    378         "target": plan.target,
    379         "source_revision": plan.source_revision,
    380         "source_tree": plan.source_tree,
    381         "root_preimage_revision": plan.root_preimage_revision,
    382         "source_date_epoch": plan.source_date_epoch,
    383         "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
    384         "build": [
    385             build_result("build-1", "checkout-1", "store-1", &first),
    386             build_result("build-2", "checkout-2", "store-2", &second)
    387         ],
    388         "reproducibility": {
    389             "first_inventory_sha256": first.sha256,
    390             "second_inventory_sha256": second.sha256,
    391             "difference": [],
    392             "result": "pass"
    393         },
    394         "predecessor_artifact_set_sha256": predecessor.sha256,
    395         "install_phase": witnesses,
    396         "result": "pass"
    397     });
    398     write_result(arguments.output, &result)
    399 }
    400 
    401 fn load_plan(path: &Path) -> Result<Plan, ReproInstallError> {
    402     let bytes = read_regular_bounded(path, MAX_PLAN_BYTES, ReproInstallError::InvalidPlan)?;
    403     let value =
    404         serde_json::from_slice::<Value>(&bytes).map_err(|_| ReproInstallError::InvalidPlan)?;
    405     if canonical_json_line(&value).map_err(|_| ReproInstallError::InvalidPlan)? != bytes {
    406         return Err(ReproInstallError::InvalidPlan);
    407     }
    408     serde_json::from_value(value).map_err(|_| ReproInstallError::InvalidPlan)
    409 }
    410 
    411 fn validate_plan(plan: &Plan) -> Result<(), ReproInstallError> {
    412     if plan.schema != PLAN_SCHEMA
    413         || !valid_hex(&plan.candidate_digest, 64)
    414         || !SUPPORTED_TARGETS.contains(&plan.target.as_str())
    415         || !valid_hex(&plan.source_revision, 40)
    416         || !valid_hex(&plan.source_tree, 40)
    417         || !valid_hex(&plan.root_preimage_revision, 40)
    418         || plan.source_date_epoch == 0
    419         || plan.normalization.kind != NORMALIZATION_KIND
    420         || !plan.normalization.excluded_paths.is_empty()
    421         || !valid_hex(&plan.git_executable_sha256, 64)
    422         || !valid_hex(&plan.adapter_executable_sha256, 64)
    423         || plan.phase.len() != PHASE_IDS.len()
    424         || plan
    425             .phase
    426             .iter()
    427             .map(|phase| phase.id.as_str())
    428             .ne(PHASE_IDS)
    429     {
    430         return Err(ReproInstallError::InvalidPlan);
    431     }
    432     validate_argv_template(&plan.build_argv, &BUILD_PLACEHOLDERS)?;
    433     for phase in &plan.phase {
    434         validate_argv_template(&phase.argv, &PHASE_PLACEHOLDERS)?;
    435     }
    436     Ok(())
    437 }
    438 
    439 fn validate_argv_template(
    440     arguments: &[String],
    441     required: &[&str],
    442 ) -> Result<(), ReproInstallError> {
    443     if arguments.is_empty() || arguments.len() > MAX_ARGV_TOKENS {
    444         return Err(ReproInstallError::InvalidPlan);
    445     }
    446     let required = required.iter().copied().collect::<BTreeSet<_>>();
    447     let mut observed = BTreeSet::new();
    448     for argument in arguments {
    449         if argument.is_empty()
    450             || argument.len() > MAX_ARGV_TOKEN_BYTES
    451             || argument
    452                 .bytes()
    453                 .any(|byte| matches!(byte, 0 | b'\r' | b'\n'))
    454         {
    455             return Err(ReproInstallError::InvalidPlan);
    456         }
    457         if (argument.contains('{') || argument.contains('}'))
    458             && (!required.contains(argument.as_str()) || !observed.insert(argument.as_str()))
    459         {
    460             return Err(ReproInstallError::InvalidPlan);
    461         }
    462     }
    463     if observed != required {
    464         return Err(ReproInstallError::InvalidPlan);
    465     }
    466     Ok(())
    467 }
    468 
    469 fn verify_source(
    470     git: &Path,
    471     source_root: &Path,
    472     home: &Path,
    473     revision: &str,
    474     tree: &str,
    475 ) -> Result<(), ReproInstallError> {
    476     let status = git_output(
    477         git,
    478         source_root,
    479         home,
    480         ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
    481     )?;
    482     if !status.stdout().is_empty() {
    483         return Err(ReproInstallError::DirtySource);
    484     }
    485     if git_line(git, source_root, home, ["rev-parse", "HEAD"])? != revision
    486         || git_line(git, source_root, home, ["rev-parse", "HEAD^{tree}"])? != tree
    487     {
    488         return Err(ReproInstallError::InvalidSource);
    489     }
    490     Ok(())
    491 }
    492 
    493 fn verify_root_preimage_epoch(
    494     git: &Path,
    495     root: &Path,
    496     home: &Path,
    497     revision: &str,
    498     epoch: u64,
    499 ) -> Result<(), ReproInstallError> {
    500     let observed = git_line(git, root, home, ["show", "-s", "--format=%ct", revision])?
    501         .parse::<u64>()
    502         .map_err(|_| ReproInstallError::InvalidSource)?;
    503     if observed != epoch {
    504         return Err(ReproInstallError::InvalidSource);
    505     }
    506     Ok(())
    507 }
    508 
    509 fn create_checkout(
    510     git: &Path,
    511     source_root: &Path,
    512     checkout: &Path,
    513     home: &Path,
    514     plan: &Plan,
    515 ) -> Result<(), ReproInstallError> {
    516     let source = os_string(source_root);
    517     let destination = os_string(checkout);
    518     let clone_arguments = [
    519         OsString::from("-c"),
    520         OsString::from("core.hooksPath=/dev/null"),
    521         OsString::from("-c"),
    522         OsString::from("protocol.file.allow=always"),
    523         OsString::from("clone"),
    524         OsString::from("--no-local"),
    525         OsString::from("--no-checkout"),
    526         OsString::from("--no-tags"),
    527         source,
    528         destination,
    529     ];
    530     run_process(
    531         git,
    532         &clone_arguments,
    533         source_root,
    534         git_environment(home)?,
    535         60,
    536         ReproInstallError::CheckoutFailure,
    537     )?;
    538     let checkout = checkout
    539         .canonicalize()
    540         .map_err(|_| ReproInstallError::CheckoutFailure)?;
    541     let checkout_arguments = [
    542         OsString::from("-c"),
    543         OsString::from("core.hooksPath=/dev/null"),
    544         OsString::from("checkout"),
    545         OsString::from("--detach"),
    546         OsString::from(&plan.source_revision),
    547     ];
    548     run_process(
    549         git,
    550         &checkout_arguments,
    551         &checkout,
    552         git_environment(home)?,
    553         60,
    554         ReproInstallError::CheckoutFailure,
    555     )?;
    556     verify_source(
    557         git,
    558         &checkout,
    559         home,
    560         &plan.source_revision,
    561         &plan.source_tree,
    562     )
    563     .map_err(|_| ReproInstallError::CheckoutFailure)
    564 }
    565 
    566 fn run_build(
    567     adapter: &Path,
    568     plan: &Plan,
    569     checkout: &Path,
    570     store: &Path,
    571     output: &Path,
    572     home: &Path,
    573 ) -> Result<(), ReproInstallError> {
    574     require_empty_directory(store, ReproInstallError::BuildFailure)?;
    575     require_empty_directory(output, ReproInstallError::BuildFailure)?;
    576     let values = BTreeMap::from([
    577         ("{checkout}", os_string(checkout)),
    578         ("{store}", os_string(store)),
    579         ("{output}", os_string(output)),
    580         (
    581             "{source_date_epoch}",
    582             OsString::from(plan.source_date_epoch.to_string()),
    583         ),
    584         ("{candidate_digest}", OsString::from(&plan.candidate_digest)),
    585         ("{target}", OsString::from(&plan.target)),
    586     ]);
    587     let arguments = resolve_arguments(&plan.build_argv, &values)?;
    588     run_process(
    589         adapter,
    590         &arguments,
    591         checkout,
    592         harness_environment(home, plan, Some(store))?,
    593         MAX_PROCESS_DEADLINE_SECONDS,
    594         ReproInstallError::BuildFailure,
    595     )?;
    596     Ok(())
    597 }
    598 
    599 #[allow(clippy::too_many_arguments)]
    600 fn run_install_phases(
    601     adapter: &Path,
    602     plan: &Plan,
    603     candidate_root: &Path,
    604     candidate_sha256: &str,
    605     predecessor_root: &Path,
    606     predecessor_sha256: &str,
    607     fresh_root: &Path,
    608     lifecycle_root: &Path,
    609     home: &Path,
    610 ) -> Result<Vec<PhaseWitness>, ReproInstallError> {
    611     let mut witnesses = Vec::with_capacity(PHASE_IDS.len());
    612     for phase in &plan.phase {
    613         let uses_candidate = matches!(
    614             phase.id.as_str(),
    615             "fresh_install_candidate"
    616                 | "fresh_health_candidate"
    617                 | "upgrade_candidate"
    618                 | "post_upgrade_health"
    619         );
    620         let install_root = if phase.id.starts_with("fresh_") {
    621             fresh_root
    622         } else {
    623             lifecycle_root
    624         };
    625         let artifact_root = if uses_candidate {
    626             candidate_root
    627         } else {
    628             predecessor_root
    629         };
    630         let artifact_sha256 = if uses_candidate {
    631             candidate_sha256
    632         } else {
    633             predecessor_sha256
    634         };
    635         let values = BTreeMap::from([
    636             ("{phase}", OsString::from(&phase.id)),
    637             ("{install_root}", os_string(install_root)),
    638             ("{artifact_root}", os_string(artifact_root)),
    639             ("{artifact_set_sha256}", OsString::from(artifact_sha256)),
    640             (
    641                 "{source_date_epoch}",
    642                 OsString::from(plan.source_date_epoch.to_string()),
    643             ),
    644             ("{candidate_digest}", OsString::from(&plan.candidate_digest)),
    645             ("{target}", OsString::from(&plan.target)),
    646         ]);
    647         let arguments = resolve_arguments(&phase.argv, &values)?;
    648         let output = run_process(
    649             adapter,
    650             &arguments,
    651             install_root,
    652             harness_environment(home, plan, None)?,
    653             MAX_PROCESS_DEADLINE_SECONDS,
    654             ReproInstallError::InstallFailure,
    655         )?;
    656         let value = serde_json::from_slice::<Value>(output.stdout())
    657             .map_err(|_| ReproInstallError::InvalidWitness)?;
    658         if canonical_json_line(&value).map_err(|_| ReproInstallError::InvalidWitness)?
    659             != output.stdout()
    660         {
    661             return Err(ReproInstallError::InvalidWitness);
    662         }
    663         let witness = serde_json::from_value::<PhaseWitness>(value)
    664             .map_err(|_| ReproInstallError::InvalidWitness)?;
    665         if witness.schema != WITNESS_SCHEMA
    666             || witness.phase != phase.id
    667             || witness.candidate_digest != plan.candidate_digest
    668             || witness.artifact_set_sha256 != artifact_sha256
    669             || witness.result != "pass"
    670             || !valid_state(&witness.before_state_sha256)
    671             || !valid_hex(&witness.after_state_sha256, 64)
    672         {
    673             return Err(ReproInstallError::InvalidWitness);
    674         }
    675         witnesses.push(witness);
    676     }
    677     Ok(witnesses)
    678 }
    679 
    680 fn validate_phase_chain(
    681     witnesses: &[PhaseWitness],
    682     candidate_digest: &str,
    683     candidate_artifacts: &str,
    684     predecessor_artifacts: &str,
    685 ) -> Result<(), ReproInstallError> {
    686     if witnesses.len() != PHASE_IDS.len()
    687         || witnesses
    688             .iter()
    689             .map(|witness| witness.phase.as_str())
    690             .ne(PHASE_IDS)
    691         || witnesses.iter().any(|witness| {
    692             witness.schema != WITNESS_SCHEMA
    693                 || witness.candidate_digest != candidate_digest
    694                 || witness.result != "pass"
    695         })
    696     {
    697         return Err(ReproInstallError::InvalidWitness);
    698     }
    699     let expected_artifacts = [
    700         candidate_artifacts,
    701         candidate_artifacts,
    702         predecessor_artifacts,
    703         predecessor_artifacts,
    704         candidate_artifacts,
    705         candidate_artifacts,
    706         predecessor_artifacts,
    707         predecessor_artifacts,
    708     ];
    709     if witnesses
    710         .iter()
    711         .zip(expected_artifacts)
    712         .any(|(witness, expected)| witness.artifact_set_sha256 != expected)
    713         || witnesses[0].before_state_sha256 != EMPTY_STATE
    714         || witnesses[1].before_state_sha256 != witnesses[0].after_state_sha256
    715         || witnesses[1].after_state_sha256 != witnesses[0].after_state_sha256
    716         || witnesses[2].before_state_sha256 != EMPTY_STATE
    717         || witnesses[3].before_state_sha256 != witnesses[2].after_state_sha256
    718         || witnesses[3].after_state_sha256 != witnesses[2].after_state_sha256
    719         || witnesses[4].before_state_sha256 != witnesses[3].after_state_sha256
    720         || witnesses[5].before_state_sha256 != witnesses[4].after_state_sha256
    721         || witnesses[5].after_state_sha256 != witnesses[4].after_state_sha256
    722         || witnesses[6].before_state_sha256 != witnesses[5].after_state_sha256
    723         || witnesses[7].before_state_sha256 != witnesses[6].after_state_sha256
    724         || witnesses[7].after_state_sha256 != witnesses[6].after_state_sha256
    725     {
    726         return Err(ReproInstallError::InvalidWitness);
    727     }
    728     Ok(())
    729 }
    730 
    731 fn artifact_inventory(root: &Path) -> Result<ArtifactInventory, ReproInstallError> {
    732     let root = canonical_directory(root, ReproInstallError::InvalidArtifacts)?;
    733     let mut entries = Vec::new();
    734     let mut total_bytes = 0_u64;
    735     for entry in walkdir::WalkDir::new(&root).follow_links(false) {
    736         let entry = entry.map_err(|_| ReproInstallError::InvalidArtifacts)?;
    737         if entry.path() == root {
    738             continue;
    739         }
    740         let metadata =
    741             fs::symlink_metadata(entry.path()).map_err(|_| ReproInstallError::InvalidArtifacts)?;
    742         if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
    743             return Err(ReproInstallError::InvalidArtifacts);
    744         }
    745         if metadata.is_dir() {
    746             continue;
    747         }
    748         if metadata.len() > MAX_ARTIFACT_FILE_BYTES || entries.len() == MAX_ARTIFACT_FILES {
    749             return Err(ReproInstallError::InvalidArtifacts);
    750         }
    751         total_bytes = total_bytes
    752             .checked_add(metadata.len())
    753             .filter(|total| *total <= MAX_ARTIFACT_TOTAL_BYTES)
    754             .ok_or(ReproInstallError::InvalidArtifacts)?;
    755         let relative = entry
    756             .path()
    757             .strip_prefix(&root)
    758             .map_err(|_| ReproInstallError::InvalidArtifacts)?;
    759         let path = portable_relative_path(relative)?;
    760         entries.push(ArtifactEntry {
    761             path,
    762             mode: file_mode(&metadata),
    763             size_bytes: metadata.len(),
    764             sha256: sha256_reader(
    765                 File::open(entry.path()).map_err(|_| ReproInstallError::InvalidArtifacts)?,
    766             )?,
    767         });
    768     }
    769     if entries.is_empty() {
    770         return Err(ReproInstallError::InvalidArtifacts);
    771     }
    772     entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
    773     if entries.windows(2).any(|pair| pair[0].path == pair[1].path) {
    774         return Err(ReproInstallError::InvalidArtifacts);
    775     }
    776     let sha256 = sha256_bytes(
    777         &serde_json::to_vec(&entries).map_err(|_| ReproInstallError::InvalidArtifacts)?,
    778     );
    779     Ok(ArtifactInventory {
    780         entries,
    781         sha256,
    782         total_bytes,
    783     })
    784 }
    785 
    786 fn compare_inventories(
    787     first: &ArtifactInventory,
    788     second: &ArtifactInventory,
    789 ) -> Vec<ArtifactDifference> {
    790     let first = first
    791         .entries
    792         .iter()
    793         .map(|entry| (entry.path.as_str(), entry))
    794         .collect::<BTreeMap<_, _>>();
    795     let second = second
    796         .entries
    797         .iter()
    798         .map(|entry| (entry.path.as_str(), entry))
    799         .collect::<BTreeMap<_, _>>();
    800     first
    801         .keys()
    802         .chain(second.keys())
    803         .copied()
    804         .collect::<BTreeSet<_>>()
    805         .into_iter()
    806         .filter_map(|path| {
    807             let left = first.get(path).copied();
    808             let right = second.get(path).copied();
    809             (left != right).then(|| ArtifactDifference {
    810                 path: path.to_owned(),
    811                 first: left.cloned(),
    812                 second: right.cloned(),
    813             })
    814         })
    815         .take(MAX_DIFF_ENTRIES)
    816         .collect()
    817 }
    818 
    819 fn build_result(
    820     id: &str,
    821     checkout_id: &str,
    822     store_id: &str,
    823     inventory: &ArtifactInventory,
    824 ) -> Value {
    825     json!({
    826         "id": id,
    827         "checkout_id": checkout_id,
    828         "store_id": store_id,
    829         "artifact_inventory_sha256": inventory.sha256,
    830         "artifact_file_count": inventory.entries.len(),
    831         "artifact_total_bytes": inventory.total_bytes,
    832         "result": "pass"
    833     })
    834 }
    835 
    836 fn failure_result(
    837     plan: &Plan,
    838     first: &ArtifactInventory,
    839     second: &ArtifactInventory,
    840     differences: &[ArtifactDifference],
    841 ) -> Value {
    842     json!({
    843         "schema": RESULT_SCHEMA,
    844         "candidate_digest": plan.candidate_digest,
    845         "target": plan.target,
    846         "source_revision": plan.source_revision,
    847         "source_tree": plan.source_tree,
    848         "root_preimage_revision": plan.root_preimage_revision,
    849         "source_date_epoch": plan.source_date_epoch,
    850         "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
    851         "build": [
    852             build_result("build-1", "checkout-1", "store-1", first),
    853             build_result("build-2", "checkout-2", "store-2", second)
    854         ],
    855         "reproducibility": {
    856             "first_inventory_sha256": first.sha256,
    857             "second_inventory_sha256": second.sha256,
    858             "difference": differences,
    859             "result": "fail"
    860         },
    861         "predecessor_artifact_set_sha256": "not_evaluated",
    862         "install_phase": [],
    863         "result": "fail"
    864     })
    865 }
    866 
    867 fn write_result(path: &Path, value: &Value) -> Result<(), ReproInstallError> {
    868     let bytes = canonical_json_line(value).map_err(|_| ReproInstallError::InvalidOutput)?;
    869     if bytes.len() > MAX_RESULT_BYTES {
    870         return Err(ReproInstallError::InvalidOutput);
    871     }
    872     let parent = path.parent().ok_or(ReproInstallError::InvalidOutput)?;
    873     let canonical_parent = parent
    874         .canonicalize()
    875         .map_err(|_| ReproInstallError::InvalidOutput)?;
    876     if canonical_parent != parent || path.exists() || path.is_symlink() {
    877         return Err(ReproInstallError::InvalidOutput);
    878     }
    879     #[cfg(unix)]
    880     let mut output = {
    881         use std::os::unix::fs::OpenOptionsExt;
    882         OpenOptions::new()
    883             .write(true)
    884             .create_new(true)
    885             .mode(0o600)
    886             .open(path)
    887             .map_err(|_| ReproInstallError::InvalidOutput)?
    888     };
    889     #[cfg(not(unix))]
    890     let mut output = OpenOptions::new()
    891         .write(true)
    892         .create_new(true)
    893         .open(path)
    894         .map_err(|_| ReproInstallError::InvalidOutput)?;
    895     output
    896         .write_all(&bytes)
    897         .and_then(|()| output.sync_all())
    898         .map_err(|_| ReproInstallError::InvalidOutput)
    899 }
    900 
    901 fn validate_output_target(path: &Path) -> Result<(), ReproInstallError> {
    902     if !path.is_absolute() || path.exists() || path.is_symlink() {
    903         return Err(ReproInstallError::InvalidOutput);
    904     }
    905     let parent = path.parent().ok_or(ReproInstallError::InvalidOutput)?;
    906     let canonical = parent
    907         .canonicalize()
    908         .map_err(|_| ReproInstallError::InvalidOutput)?;
    909     if canonical != parent {
    910         return Err(ReproInstallError::InvalidOutput);
    911     }
    912     Ok(())
    913 }
    914 
    915 fn validate_executable(path: &Path, expected_sha256: &str) -> Result<(), ReproInstallError> {
    916     if !path.is_absolute() || !valid_hex(expected_sha256, 64) {
    917         return Err(ReproInstallError::InvalidTool);
    918     }
    919     let metadata = fs::symlink_metadata(path).map_err(|_| ReproInstallError::InvalidTool)?;
    920     if !metadata.is_file() || metadata.file_type().is_symlink() || !executable_mode(&metadata) {
    921         return Err(ReproInstallError::InvalidTool);
    922     }
    923     let observed = sha256_reader(File::open(path).map_err(|_| ReproInstallError::InvalidTool)?)
    924         .map_err(|_| ReproInstallError::InvalidTool)?;
    925     if observed != expected_sha256 {
    926         return Err(ReproInstallError::InvalidTool);
    927     }
    928     Ok(())
    929 }
    930 
    931 fn read_regular_bounded(
    932     path: &Path,
    933     maximum: u64,
    934     error: ReproInstallError,
    935 ) -> Result<Vec<u8>, ReproInstallError> {
    936     let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
    937     if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > maximum {
    938         return Err(error);
    939     }
    940     fs::read(path).map_err(|_| error)
    941 }
    942 
    943 fn canonical_directory(
    944     path: &Path,
    945     error: ReproInstallError,
    946 ) -> Result<PathBuf, ReproInstallError> {
    947     if !path.is_absolute() {
    948         return Err(error);
    949     }
    950     let canonical = path.canonicalize().map_err(|_| error)?;
    951     let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
    952     if canonical != path || !metadata.is_dir() || metadata.file_type().is_symlink() {
    953         return Err(error);
    954     }
    955     Ok(canonical)
    956 }
    957 
    958 fn create_owned_directory(path: &Path) -> Result<PathBuf, ReproInstallError> {
    959     fs::create_dir(path).map_err(|_| ReproInstallError::InvalidOutput)?;
    960     path.canonicalize()
    961         .map_err(|_| ReproInstallError::InvalidOutput)
    962 }
    963 
    964 fn require_empty_directory(path: &Path, error: ReproInstallError) -> Result<(), ReproInstallError> {
    965     let mut entries = fs::read_dir(path).map_err(|_| error)?;
    966     if entries.next().transpose().map_err(|_| error)?.is_some() {
    967         return Err(error);
    968     }
    969     Ok(())
    970 }
    971 
    972 fn require_distinct(
    973     left: &Path,
    974     right: &Path,
    975     error: ReproInstallError,
    976 ) -> Result<(), ReproInstallError> {
    977     if left == right {
    978         return Err(error);
    979     }
    980     let left_metadata = fs::metadata(left).map_err(|_| error)?;
    981     let right_metadata = fs::metadata(right).map_err(|_| error)?;
    982     if same_file(&left_metadata, &right_metadata) {
    983         return Err(error);
    984     }
    985     Ok(())
    986 }
    987 
    988 #[cfg(unix)]
    989 fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
    990     use std::os::unix::fs::MetadataExt;
    991     left.dev() == right.dev() && left.ino() == right.ino()
    992 }
    993 
    994 #[cfg(not(unix))]
    995 fn same_file(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
    996     false
    997 }
    998 
    999 fn run_process(
   1000     program: &Path,
   1001     arguments: &[OsString],
   1002     current_dir: &Path,
   1003     environment: ReplacementEnvironment,
   1004     deadline_seconds: u64,
   1005     error: ReproInstallError,
   1006 ) -> Result<ProcessOutput, ReproInstallError> {
   1007     let mut request = ProcessRequest::new(program.as_os_str())
   1008         .current_dir(current_dir)
   1009         .environment(environment)
   1010         .deadline(Duration::from_secs(deadline_seconds))
   1011         .output_limits(MAX_PROCESS_STREAM_BYTES, MAX_PROCESS_STREAM_BYTES);
   1012     for argument in arguments {
   1013         request = request.arg(argument);
   1014     }
   1015     let output = bounded_process::run(&request).map_err(|_| error)?;
   1016     if !output.status().success() {
   1017         return Err(error);
   1018     }
   1019     Ok(output)
   1020 }
   1021 
   1022 fn git_output<const N: usize>(
   1023     git: &Path,
   1024     current_dir: &Path,
   1025     home: &Path,
   1026     arguments: [&str; N],
   1027 ) -> Result<ProcessOutput, ReproInstallError> {
   1028     let arguments = arguments.map(OsString::from);
   1029     run_process(
   1030         git,
   1031         &arguments,
   1032         current_dir,
   1033         git_environment(home)?,
   1034         60,
   1035         ReproInstallError::InvalidSource,
   1036     )
   1037 }
   1038 
   1039 fn git_line<const N: usize>(
   1040     git: &Path,
   1041     current_dir: &Path,
   1042     home: &Path,
   1043     arguments: [&str; N],
   1044 ) -> Result<String, ReproInstallError> {
   1045     let output = git_output(git, current_dir, home, arguments)?;
   1046     let text =
   1047         std::str::from_utf8(output.stdout()).map_err(|_| ReproInstallError::InvalidSource)?;
   1048     let line = text
   1049         .strip_suffix('\n')
   1050         .ok_or(ReproInstallError::InvalidSource)?;
   1051     if line.is_empty() || line.contains(['\r', '\n']) {
   1052         return Err(ReproInstallError::InvalidSource);
   1053     }
   1054     Ok(line.to_owned())
   1055 }
   1056 
   1057 fn git_environment(home: &Path) -> Result<ReplacementEnvironment, ReproInstallError> {
   1058     let mut environment = ReplacementEnvironment::default();
   1059     insert_environment(&mut environment, "HOME", home.as_os_str())?;
   1060     insert_environment(&mut environment, "LC_ALL", "C")?;
   1061     insert_environment(&mut environment, "TZ", "UTC")?;
   1062     insert_environment(&mut environment, "GIT_CONFIG_NOSYSTEM", "1")?;
   1063     Ok(environment)
   1064 }
   1065 
   1066 fn harness_environment(
   1067     home: &Path,
   1068     plan: &Plan,
   1069     store: Option<&Path>,
   1070 ) -> Result<ReplacementEnvironment, ReproInstallError> {
   1071     let mut environment = ReplacementEnvironment::default();
   1072     insert_environment(&mut environment, "HOME", home.as_os_str())?;
   1073     insert_environment(&mut environment, "LC_ALL", "C")?;
   1074     insert_environment(&mut environment, "TZ", "UTC")?;
   1075     insert_environment(
   1076         &mut environment,
   1077         "SOURCE_DATE_EPOCH",
   1078         plan.source_date_epoch.to_string(),
   1079     )?;
   1080     insert_environment(
   1081         &mut environment,
   1082         "RSHR_CANDIDATE_DIGEST",
   1083         &plan.candidate_digest,
   1084     )?;
   1085     if let Some(store) = store {
   1086         insert_environment(&mut environment, "RSHR_BUILD_STORE", store.as_os_str())?;
   1087     }
   1088     Ok(environment)
   1089 }
   1090 
   1091 fn insert_environment(
   1092     environment: &mut ReplacementEnvironment,
   1093     name: &str,
   1094     value: impl Into<OsString>,
   1095 ) -> Result<(), ReproInstallError> {
   1096     environment
   1097         .insert(name, value)
   1098         .map_err(|_| ReproInstallError::InvalidPlan)
   1099 }
   1100 
   1101 fn resolve_arguments(
   1102     template: &[String],
   1103     values: &BTreeMap<&str, OsString>,
   1104 ) -> Result<Vec<OsString>, ReproInstallError> {
   1105     template
   1106         .iter()
   1107         .map(|argument| {
   1108             if argument.contains('{') || argument.contains('}') {
   1109                 values
   1110                     .get(argument.as_str())
   1111                     .cloned()
   1112                     .ok_or(ReproInstallError::InvalidPlan)
   1113             } else {
   1114                 Ok(OsString::from(argument))
   1115             }
   1116         })
   1117         .collect()
   1118 }
   1119 
   1120 fn portable_relative_path(path: &Path) -> Result<String, ReproInstallError> {
   1121     let mut parts = Vec::new();
   1122     for component in path.components() {
   1123         let Component::Normal(part) = component else {
   1124             return Err(ReproInstallError::InvalidArtifacts);
   1125         };
   1126         let part = part.to_str().ok_or(ReproInstallError::InvalidArtifacts)?;
   1127         if part.is_empty()
   1128             || part == "."
   1129             || part == ".."
   1130             || part.contains(['/', '\\', '\0', '\r', '\n'])
   1131         {
   1132             return Err(ReproInstallError::InvalidArtifacts);
   1133         }
   1134         parts.push(part);
   1135     }
   1136     if parts.is_empty() {
   1137         return Err(ReproInstallError::InvalidArtifacts);
   1138     }
   1139     Ok(parts.join("/"))
   1140 }
   1141 
   1142 fn sha256_reader(mut reader: impl Read) -> Result<String, ReproInstallError> {
   1143     let mut digest = Sha256::new();
   1144     let mut buffer = [0_u8; 64 * 1024];
   1145     loop {
   1146         let count = reader
   1147             .read(&mut buffer)
   1148             .map_err(|_| ReproInstallError::InvalidArtifacts)?;
   1149         if count == 0 {
   1150             break;
   1151         }
   1152         digest.update(&buffer[..count]);
   1153     }
   1154     Ok(hex::encode(digest.finalize()))
   1155 }
   1156 
   1157 fn sha256_bytes(bytes: &[u8]) -> String {
   1158     hex::encode(Sha256::digest(bytes))
   1159 }
   1160 
   1161 fn canonical_json_line(value: &Value) -> Result<Vec<u8>, serde_json::Error> {
   1162     let mut bytes = serde_json::to_vec(value)?;
   1163     bytes.push(b'\n');
   1164     Ok(bytes)
   1165 }
   1166 
   1167 fn valid_hex(value: &str, length: usize) -> bool {
   1168     value.len() == length
   1169         && value
   1170             .bytes()
   1171             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
   1172 }
   1173 
   1174 fn valid_state(value: &str) -> bool {
   1175     value == EMPTY_STATE || valid_hex(value, 64)
   1176 }
   1177 
   1178 fn os_string(path: &Path) -> OsString {
   1179     path.as_os_str().to_owned()
   1180 }
   1181 
   1182 #[cfg(unix)]
   1183 fn file_mode(metadata: &fs::Metadata) -> u32 {
   1184     use std::os::unix::fs::PermissionsExt;
   1185     metadata.permissions().mode() & 0o777
   1186 }
   1187 
   1188 #[cfg(not(unix))]
   1189 fn file_mode(metadata: &fs::Metadata) -> u32 {
   1190     if metadata.permissions().readonly() {
   1191         0o444
   1192     } else {
   1193         0o666
   1194     }
   1195 }
   1196 
   1197 #[cfg(unix)]
   1198 fn executable_mode(metadata: &fs::Metadata) -> bool {
   1199     use std::os::unix::fs::PermissionsExt;
   1200     metadata.permissions().mode() & 0o111 != 0
   1201 }
   1202 
   1203 #[cfg(not(unix))]
   1204 fn executable_mode(_metadata: &fs::Metadata) -> bool {
   1205     true
   1206 }
   1207 
   1208 #[cfg(test)]
   1209 mod tests {
   1210     use super::*;
   1211     use std::process::Command;
   1212     use tempfile::TempDir;
   1213 
   1214     fn sample_plan_value() -> Value {
   1215         json!({
   1216             "schema": PLAN_SCHEMA,
   1217             "candidate_digest": "a".repeat(64),
   1218             "target": "aarch64-apple-darwin",
   1219             "source_revision": "b".repeat(40),
   1220             "source_tree": "c".repeat(40),
   1221             "root_preimage_revision": "d".repeat(40),
   1222             "source_date_epoch": 1_700_000_000_u64,
   1223             "normalization": {"kind": NORMALIZATION_KIND, "excluded_paths": []},
   1224             "git_executable_sha256": "e".repeat(64),
   1225             "adapter_executable_sha256": "f".repeat(64),
   1226             "build_argv": BUILD_PLACEHOLDERS,
   1227             "phase": PHASE_IDS.map(|id| json!({"id": id, "argv": PHASE_PLACEHOLDERS}))
   1228         })
   1229     }
   1230 
   1231     fn sample_plan() -> Plan {
   1232         serde_json::from_value(sample_plan_value()).expect("sample plan")
   1233     }
   1234 
   1235     fn witness(phase: &str, artifacts: &str, before: &str, after: &str) -> PhaseWitness {
   1236         PhaseWitness {
   1237             schema: WITNESS_SCHEMA.to_owned(),
   1238             phase: phase.to_owned(),
   1239             candidate_digest: "a".repeat(64),
   1240             artifact_set_sha256: artifacts.to_owned(),
   1241             before_state_sha256: before.to_owned(),
   1242             after_state_sha256: after.to_owned(),
   1243             result: "pass".to_owned(),
   1244         }
   1245     }
   1246 
   1247     fn valid_witnesses() -> Vec<PhaseWitness> {
   1248         let candidate = "1".repeat(64);
   1249         let predecessor = "2".repeat(64);
   1250         let fresh = "3".repeat(64);
   1251         let installed = "4".repeat(64);
   1252         let upgraded = "5".repeat(64);
   1253         let rolled_back = "6".repeat(64);
   1254         vec![
   1255             witness(PHASE_IDS[0], &candidate, EMPTY_STATE, &fresh),
   1256             witness(PHASE_IDS[1], &candidate, &fresh, &fresh),
   1257             witness(PHASE_IDS[2], &predecessor, EMPTY_STATE, &installed),
   1258             witness(PHASE_IDS[3], &predecessor, &installed, &installed),
   1259             witness(PHASE_IDS[4], &candidate, &installed, &upgraded),
   1260             witness(PHASE_IDS[5], &candidate, &upgraded, &upgraded),
   1261             witness(PHASE_IDS[6], &predecessor, &upgraded, &rolled_back),
   1262             witness(PHASE_IDS[7], &predecessor, &rolled_back, &rolled_back),
   1263         ]
   1264     }
   1265 
   1266     fn write_file(path: &Path, bytes: &[u8]) {
   1267         fs::create_dir_all(path.parent().expect("parent")).expect("directory");
   1268         fs::write(path, bytes).expect("file");
   1269     }
   1270 
   1271     fn program_path(name: &str) -> PathBuf {
   1272         std::env::split_paths(&std::env::var_os("PATH").expect("PATH"))
   1273             .map(|directory| directory.join(name))
   1274             .find(|candidate| candidate.is_file())
   1275             .expect("program on PATH")
   1276             .canonicalize()
   1277             .expect("canonical program")
   1278     }
   1279 
   1280     fn fixture_git(root: &Path, arguments: &[&str]) -> String {
   1281         let output = Command::new(program_path("git"))
   1282             .args(arguments)
   1283             .current_dir(root)
   1284             .env("GIT_CONFIG_NOSYSTEM", "1")
   1285             .output()
   1286             .expect("fixture git");
   1287         assert!(
   1288             output.status.success(),
   1289             "fixture git failed: {}",
   1290             String::from_utf8_lossy(&output.stderr)
   1291         );
   1292         String::from_utf8(output.stdout)
   1293             .expect("fixture git output")
   1294             .trim()
   1295             .to_owned()
   1296     }
   1297 
   1298     fn create_repository(path: &Path, filename: &str) {
   1299         fs::create_dir(path).expect("repository directory");
   1300         fixture_git(path, &["init", "--quiet", "--initial-branch=master"]);
   1301         fixture_git(path, &["config", "user.name", "Radroots Test"]);
   1302         fixture_git(path, &["config", "user.email", "test@radroots.invalid"]);
   1303         write_file(&path.join(filename), b"tracked\n");
   1304         fixture_git(path, &["add", filename]);
   1305         fixture_git(path, &["commit", "--quiet", "-m", "fixture"]);
   1306     }
   1307 
   1308     #[cfg(unix)]
   1309     fn write_fixture_adapter(path: &Path) {
   1310         use std::os::unix::fs::PermissionsExt;
   1311 
   1312         let script = r#"#!/bin/sh
   1313 set -eu
   1314 if [ "$1" = "build" ]; then
   1315     printf '%s\n' 'exact candidate artifact' > "$4/release.bin"
   1316     exit 0
   1317 fi
   1318 if [ "$1" != "phase" ]; then
   1319     exit 2
   1320 fi
   1321 phase="$2"
   1322 install_root="$3"
   1323 if [ -f "$install_root/state" ]; then
   1324     IFS= read -r before < "$install_root/state"
   1325 else
   1326     before=empty
   1327 fi
   1328 case "$phase" in
   1329     fresh_install_candidate) after=3333333333333333333333333333333333333333333333333333333333333333 ;;
   1330     fresh_health_candidate) after="$before" ;;
   1331     install_predecessor) after=4444444444444444444444444444444444444444444444444444444444444444 ;;
   1332     pre_upgrade_health) after="$before" ;;
   1333     upgrade_candidate) after=5555555555555555555555555555555555555555555555555555555555555555 ;;
   1334     post_upgrade_health) after="$before" ;;
   1335     rollback_predecessor) after=6666666666666666666666666666666666666666666666666666666666666666 ;;
   1336     post_rollback_health) after="$before" ;;
   1337     *) exit 2 ;;
   1338 esac
   1339 printf '%s\n' "$after" > "$install_root/state"
   1340 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"
   1341 "#;
   1342         fs::write(path, script).expect("adapter");
   1343         fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("adapter mode");
   1344     }
   1345 
   1346     #[test]
   1347     fn plan_and_argument_admission_rejects_each_independent_policy_violation() {
   1348         for (pointer, replacement) in [
   1349             ("/schema", json!("unknown")),
   1350             ("/candidate_digest", json!("A".repeat(64))),
   1351             ("/target", json!("unknown")),
   1352             ("/source_revision", json!("invalid")),
   1353             ("/source_tree", json!("invalid")),
   1354             ("/root_preimage_revision", json!("invalid")),
   1355             ("/source_date_epoch", json!(0)),
   1356             ("/normalization/kind", json!("ignore_timestamps")),
   1357             ("/normalization/excluded_paths", json!(["secret"])),
   1358             ("/git_executable_sha256", json!("invalid")),
   1359             ("/adapter_executable_sha256", json!("invalid")),
   1360             ("/phase", json!([])),
   1361             ("/phase/0/id", json!("upgrade_candidate")),
   1362             ("/phase/0/argv", json!([])),
   1363         ] {
   1364             let mut value = sample_plan_value();
   1365             *value.pointer_mut(pointer).unwrap() = replacement;
   1366             assert_eq!(
   1367                 validate_plan(&serde_json::from_value(value).unwrap()),
   1368                 Err(ReproInstallError::InvalidPlan),
   1369                 "{pointer}"
   1370             );
   1371         }
   1372         for arguments in [
   1373             vec![],
   1374             vec!["x".to_owned(); MAX_ARGV_TOKENS + 1],
   1375             vec![String::new()],
   1376             vec!["x".repeat(MAX_ARGV_TOKEN_BYTES + 1)],
   1377             vec!["a\nb".to_owned()],
   1378             vec!["a\0b".to_owned()],
   1379             vec!["{unknown}".to_owned()],
   1380             vec!["trailing}".to_owned()],
   1381             vec!["{checkout}".to_owned(), "{checkout}".to_owned()],
   1382             vec!["literal".to_owned()],
   1383         ] {
   1384             assert_eq!(
   1385                 validate_argv_template(&arguments, &["{checkout}"]),
   1386                 Err(ReproInstallError::InvalidPlan)
   1387             );
   1388         }
   1389         let values = BTreeMap::from([("{checkout}", OsString::from("/fixture"))]);
   1390         assert_eq!(
   1391             resolve_arguments(&["literal".to_owned(), "{checkout}".to_owned()], &values).unwrap(),
   1392             [OsString::from("literal"), OsString::from("/fixture")]
   1393         );
   1394         assert_eq!(
   1395             resolve_arguments(&["{unknown}".to_owned()], &values),
   1396             Err(ReproInstallError::InvalidPlan)
   1397         );
   1398         assert_eq!(
   1399             resolve_arguments(&["trailing}".to_owned()], &values),
   1400             Err(ReproInstallError::InvalidPlan)
   1401         );
   1402     }
   1403 
   1404     #[test]
   1405     fn phase_chain_rejects_each_broken_binding_transition_and_health_mutation() {
   1406         let candidate = "a".repeat(64);
   1407         let artifacts = "1".repeat(64);
   1408         let predecessor = "2".repeat(64);
   1409         let validate = |rows: &[PhaseWitness]| {
   1410             validate_phase_chain(rows, &candidate, &artifacts, &predecessor)
   1411         };
   1412         validate(&valid_witnesses()).unwrap();
   1413         assert_eq!(validate(&[]), Err(ReproInstallError::InvalidWitness));
   1414         let mut reordered = valid_witnesses();
   1415         reordered.swap(0, 1);
   1416         assert_eq!(validate(&reordered), Err(ReproInstallError::InvalidWitness));
   1417         for index in 0..PHASE_IDS.len() {
   1418             for field in [
   1419                 "schema",
   1420                 "candidate_digest",
   1421                 "artifact_set_sha256",
   1422                 "before_state_sha256",
   1423                 "result",
   1424             ] {
   1425                 let mut value = serde_json::to_value(valid_witnesses()).unwrap();
   1426                 value[index][field] = json!("mismatch");
   1427                 let rows: Vec<PhaseWitness> = serde_json::from_value(value).unwrap();
   1428                 assert_eq!(
   1429                     validate(&rows),
   1430                     Err(ReproInstallError::InvalidWitness),
   1431                     "{index} {field}"
   1432                 );
   1433             }
   1434         }
   1435         for index in [1, 3, 5, 7] {
   1436             let mut rows = valid_witnesses();
   1437             rows[index].after_state_sha256 = "7".repeat(64);
   1438             assert_eq!(validate(&rows), Err(ReproInstallError::InvalidWitness));
   1439         }
   1440     }
   1441 
   1442     #[cfg(unix)]
   1443     #[test]
   1444     fn install_receipts_reject_noncanonical_bytes_and_unbound_adapter_claims() {
   1445         use std::os::unix::fs::PermissionsExt;
   1446         let temporary = TempDir::new().unwrap();
   1447         let root = temporary.path().canonicalize().unwrap();
   1448         let adapter = root.join("adapter");
   1449         let witness = serde_json::to_value(&valid_witnesses()[0]).unwrap();
   1450         let mut invalid_outputs = vec![
   1451             b"not json".to_vec(),
   1452             serde_json::to_vec_pretty(&witness).unwrap(),
   1453             b"{}\n".to_vec(),
   1454         ];
   1455         for field in [
   1456             "schema",
   1457             "phase",
   1458             "candidate_digest",
   1459             "artifact_set_sha256",
   1460             "result",
   1461             "before_state_sha256",
   1462             "after_state_sha256",
   1463         ] {
   1464             let mut changed = witness.clone();
   1465             changed[field] = json!("unbound");
   1466             invalid_outputs.push(canonical_json_line(&changed).unwrap());
   1467         }
   1468         for bytes in invalid_outputs {
   1469             let text = String::from_utf8(bytes).unwrap();
   1470             assert!(!text.contains('\''));
   1471             fs::write(&adapter, format!("#!/bin/sh\nprintf '%s' '{text}'\n")).unwrap();
   1472             fs::set_permissions(&adapter, fs::Permissions::from_mode(0o700)).unwrap();
   1473             assert!(matches!(
   1474                 run_install_phases(
   1475                     &adapter,
   1476                     &sample_plan(),
   1477                     &root,
   1478                     &"1".repeat(64),
   1479                     &root,
   1480                     &"2".repeat(64),
   1481                     &root,
   1482                     &root,
   1483                     &root
   1484                 ),
   1485                 Err(ReproInstallError::InvalidWitness)
   1486             ));
   1487         }
   1488         fs::write(&adapter, b"#!/bin/sh\nexit 3\n").unwrap();
   1489         assert!(matches!(
   1490             run_install_phases(
   1491                 &adapter,
   1492                 &sample_plan(),
   1493                 &root,
   1494                 &"1".repeat(64),
   1495                 &root,
   1496                 &"2".repeat(64),
   1497                 &root,
   1498                 &root,
   1499                 &root
   1500             ),
   1501             Err(ReproInstallError::InstallFailure)
   1502         ));
   1503     }
   1504 
   1505     #[cfg(unix)]
   1506     #[test]
   1507     fn filesystem_and_source_admission_preserves_existing_outputs() {
   1508         use std::os::unix::fs::{PermissionsExt, symlink};
   1509         let temporary = TempDir::new().unwrap();
   1510         let root = temporary.path().canonicalize().unwrap();
   1511         let file = root.join("file");
   1512         fs::write(&file, b"preserve").unwrap();
   1513         let dangling = root.join("dangling");
   1514         symlink(root.join("missing"), &dangling).unwrap();
   1515         let linked = root.join("linked");
   1516         symlink(&root, &linked).unwrap();
   1517         for path in [
   1518             Path::new("relative"),
   1519             &file,
   1520             &dangling,
   1521             &linked.join("result"),
   1522             &root.join("missing/result"),
   1523         ] {
   1524             assert_eq!(
   1525                 validate_output_target(path),
   1526                 Err(ReproInstallError::InvalidOutput)
   1527             );
   1528         }
   1529         assert_eq!(
   1530             write_result(&dangling, &json!({})),
   1531             Err(ReproInstallError::InvalidOutput)
   1532         );
   1533         assert_eq!(
   1534             write_result(&linked.join("result"), &json!({})),
   1535             Err(ReproInstallError::InvalidOutput)
   1536         );
   1537         assert_eq!(
   1538             write_result(
   1539                 &root.join("too-large"),
   1540                 &json!("x".repeat(MAX_RESULT_BYTES))
   1541             ),
   1542             Err(ReproInstallError::InvalidOutput)
   1543         );
   1544         for path in [Path::new("relative"), &file, &linked] {
   1545             assert!(canonical_directory(path, ReproInstallError::InvalidArtifacts).is_err());
   1546         }
   1547         assert_eq!(
   1548             validate_executable(Path::new("relative"), &"0".repeat(64)),
   1549             Err(ReproInstallError::InvalidTool)
   1550         );
   1551         assert_eq!(
   1552             validate_executable(&file, "invalid"),
   1553             Err(ReproInstallError::InvalidTool)
   1554         );
   1555         assert_eq!(
   1556             validate_executable(&root, &"0".repeat(64)),
   1557             Err(ReproInstallError::InvalidTool)
   1558         );
   1559         fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
   1560         assert_eq!(
   1561             validate_executable(&file, &sha256_bytes(b"preserve")),
   1562             Err(ReproInstallError::InvalidTool)
   1563         );
   1564         for path in [&root, &linked, &file] {
   1565             assert_eq!(
   1566                 read_regular_bounded(path, 2, ReproInstallError::InvalidPlan),
   1567                 Err(ReproInstallError::InvalidPlan)
   1568             );
   1569         }
   1570         assert_eq!(
   1571             require_empty_directory(&root, ReproInstallError::BuildFailure),
   1572             Err(ReproInstallError::BuildFailure)
   1573         );
   1574         assert_eq!(
   1575             require_distinct(&root, &linked, ReproInstallError::BuildFailure),
   1576             Err(ReproInstallError::BuildFailure)
   1577         );
   1578         for path in ["", "/absolute", "../escape", ".", "a\\b", "a\nb", "a\0b"] {
   1579             assert_eq!(
   1580                 portable_relative_path(Path::new(path)),
   1581                 Err(ReproInstallError::InvalidArtifacts)
   1582             );
   1583         }
   1584         let empty = root.join("empty");
   1585         fs::create_dir(&empty).unwrap();
   1586         assert!(matches!(
   1587             artifact_inventory(&empty),
   1588             Err(ReproInstallError::InvalidArtifacts)
   1589         ));
   1590         let source = root.join("source");
   1591         create_repository(&source, "source.txt");
   1592         let git = program_path("git");
   1593         let revision = fixture_git(&source, &["rev-parse", "HEAD"]);
   1594         let tree = fixture_git(&source, &["rev-parse", "HEAD^{tree}"]);
   1595         assert_eq!(
   1596             verify_source(&git, &source, &root, &"0".repeat(40), &tree),
   1597             Err(ReproInstallError::InvalidSource)
   1598         );
   1599         assert_eq!(
   1600             verify_source(&git, &source, &root, &revision, &"0".repeat(40)),
   1601             Err(ReproInstallError::InvalidSource)
   1602         );
   1603         assert_eq!(
   1604             verify_root_preimage_epoch(&git, &source, &root, &revision, 1),
   1605             Err(ReproInstallError::InvalidSource)
   1606         );
   1607         assert_eq!(fs::read(&file).unwrap(), b"preserve");
   1608     }
   1609 
   1610     #[test]
   1611     fn decision_contract_is_exact() {
   1612         validate_contract_inner(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
   1613             .expect("decision contract");
   1614         assert_eq!(
   1615             [
   1616                 ReproInstallError::InvalidContract,
   1617                 ReproInstallError::InvalidPlan,
   1618                 ReproInstallError::InvalidSource,
   1619                 ReproInstallError::DirtySource,
   1620                 ReproInstallError::InvalidTool,
   1621                 ReproInstallError::CheckoutFailure,
   1622                 ReproInstallError::BuildFailure,
   1623                 ReproInstallError::InvalidArtifacts,
   1624                 ReproInstallError::ReproducibilityMismatch,
   1625                 ReproInstallError::InstallFailure,
   1626                 ReproInstallError::InvalidWitness,
   1627                 ReproInstallError::InvalidOutput,
   1628             ]
   1629             .map(ReproInstallError::code),
   1630             [
   1631                 "invalid_contract",
   1632                 "invalid_plan",
   1633                 "invalid_source",
   1634                 "dirty_source",
   1635                 "invalid_tool",
   1636                 "checkout_failure",
   1637                 "build_failure",
   1638                 "invalid_artifacts",
   1639                 "reproducibility_mismatch",
   1640                 "install_failure",
   1641                 "invalid_witness",
   1642                 "invalid_output",
   1643             ]
   1644         );
   1645     }
   1646 
   1647     #[test]
   1648     fn plan_requires_closed_normalization_tools_and_ordered_phases() {
   1649         validate_plan(&sample_plan()).expect("valid plan");
   1650         let mut open = sample_plan_value();
   1651         open["normalization"]["excluded_paths"] = json!(["build-id"]);
   1652         assert_eq!(
   1653             validate_plan(&serde_json::from_value(open).expect("open plan")),
   1654             Err(ReproInstallError::InvalidPlan)
   1655         );
   1656         let mut reordered = sample_plan_value();
   1657         reordered["phase"]
   1658             .as_array_mut()
   1659             .expect("phases")
   1660             .swap(0, 1);
   1661         assert_eq!(
   1662             validate_plan(&serde_json::from_value(reordered).expect("reordered plan")),
   1663             Err(ReproInstallError::InvalidPlan)
   1664         );
   1665         let mut missing_placeholder = sample_plan_value();
   1666         missing_placeholder["build_argv"] = json!(&BUILD_PLACEHOLDERS[1..]);
   1667         assert_eq!(
   1668             validate_plan(
   1669                 &serde_json::from_value(missing_placeholder).expect("missing placeholder")
   1670             ),
   1671             Err(ReproInstallError::InvalidPlan)
   1672         );
   1673     }
   1674 
   1675     #[test]
   1676     fn canonical_plan_and_witness_reject_noncanonical_bytes() {
   1677         let temporary = TempDir::new().expect("temporary");
   1678         let plan_path = temporary.path().join("plan.json");
   1679         let canonical = canonical_json_line(&sample_plan_value()).expect("canonical plan");
   1680         fs::write(&plan_path, &canonical).expect("plan");
   1681         load_plan(&plan_path).expect("canonical accepted");
   1682         let mut pretty = serde_json::to_vec_pretty(&sample_plan_value()).expect("pretty");
   1683         pretty.push(b'\n');
   1684         fs::write(&plan_path, pretty).expect("pretty plan");
   1685         assert!(matches!(
   1686             load_plan(&plan_path),
   1687             Err(ReproInstallError::InvalidPlan)
   1688         ));
   1689 
   1690         let value = serde_json::to_value(&valid_witnesses()[0]).expect("witness value");
   1691         let canonical = canonical_json_line(&value).expect("canonical witness");
   1692         assert_eq!(canonical.last(), Some(&b'\n'));
   1693         assert_ne!(
   1694             serde_json::to_vec_pretty(&value).expect("pretty witness"),
   1695             canonical
   1696         );
   1697     }
   1698 
   1699     #[test]
   1700     fn exact_artifact_inventory_reports_path_mode_size_and_digest_differences() {
   1701         let temporary = TempDir::new().expect("temporary");
   1702         let first_root = temporary.path().join("first");
   1703         let second_root = temporary.path().join("second");
   1704         write_file(&first_root.join("bin/service"), b"same");
   1705         write_file(&second_root.join("bin/service"), b"same");
   1706         let first = artifact_inventory(&first_root.canonicalize().expect("first root"))
   1707             .expect("first inventory");
   1708         let second = artifact_inventory(&second_root.canonicalize().expect("second root"))
   1709             .expect("second inventory");
   1710         assert!(compare_inventories(&first, &second).is_empty());
   1711 
   1712         write_file(&second_root.join("bin/service"), b"changed");
   1713         write_file(&second_root.join("extra"), b"extra");
   1714         let second = artifact_inventory(&second_root.canonicalize().expect("second root"))
   1715             .expect("changed inventory");
   1716         let differences = compare_inventories(&first, &second);
   1717         assert_eq!(
   1718             differences
   1719                 .iter()
   1720                 .map(|difference| difference.path.as_str())
   1721                 .collect::<Vec<_>>(),
   1722             ["bin/service", "extra"]
   1723         );
   1724     }
   1725 
   1726     #[cfg(unix)]
   1727     #[test]
   1728     fn artifact_inventory_rejects_symlinks_and_binds_mode() {
   1729         use std::os::unix::fs::{PermissionsExt, symlink};
   1730 
   1731         let temporary = TempDir::new().expect("temporary");
   1732         let root = temporary.path().join("artifacts");
   1733         write_file(&root.join("service"), b"service");
   1734         fs::set_permissions(root.join("service"), fs::Permissions::from_mode(0o755)).expect("mode");
   1735         let executable =
   1736             artifact_inventory(&root.canonicalize().expect("root")).expect("executable inventory");
   1737         assert_eq!(executable.entries[0].mode, 0o755);
   1738         symlink("service", root.join("linked")).expect("symlink");
   1739         assert!(matches!(
   1740             artifact_inventory(&root.canonicalize().expect("root")),
   1741             Err(ReproInstallError::InvalidArtifacts)
   1742         ));
   1743     }
   1744 
   1745     #[test]
   1746     fn install_upgrade_and_rollback_witnesses_form_exact_chains() {
   1747         let candidate = "1".repeat(64);
   1748         let predecessor = "2".repeat(64);
   1749         let witnesses = valid_witnesses();
   1750         validate_phase_chain(&witnesses, &"a".repeat(64), &candidate, &predecessor)
   1751             .expect("valid phase chain");
   1752 
   1753         let mut mutating_health = valid_witnesses();
   1754         mutating_health[5].after_state_sha256 = "7".repeat(64);
   1755         assert_eq!(
   1756             validate_phase_chain(&mutating_health, &"a".repeat(64), &candidate, &predecessor),
   1757             Err(ReproInstallError::InvalidWitness)
   1758         );
   1759         let mut broken_rollback = valid_witnesses();
   1760         broken_rollback[6].before_state_sha256 = "8".repeat(64);
   1761         assert_eq!(
   1762             validate_phase_chain(&broken_rollback, &"a".repeat(64), &candidate, &predecessor),
   1763             Err(ReproInstallError::InvalidWitness)
   1764         );
   1765     }
   1766 
   1767     #[cfg(unix)]
   1768     #[test]
   1769     fn exact_tool_bytes_and_create_new_output_fail_closed() {
   1770         use std::os::unix::fs::{PermissionsExt, symlink};
   1771 
   1772         let temporary = TempDir::new().expect("temporary");
   1773         let root = temporary.path().canonicalize().expect("root");
   1774         let tool = root.join("tool");
   1775         fs::write(&tool, b"tool").expect("tool");
   1776         fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("mode");
   1777         validate_executable(&tool, &sha256_bytes(b"tool")).expect("valid tool");
   1778         assert_eq!(
   1779             validate_executable(&tool, &sha256_bytes(b"replacement")),
   1780             Err(ReproInstallError::InvalidTool)
   1781         );
   1782         let link = root.join("tool-link");
   1783         symlink(&tool, &link).expect("tool symlink");
   1784         assert_eq!(
   1785             validate_executable(&link, &sha256_bytes(b"tool")),
   1786             Err(ReproInstallError::InvalidTool)
   1787         );
   1788 
   1789         let output = root.join("result.json");
   1790         write_result(&output, &json!({"result": "pass"})).expect("first result");
   1791         assert_eq!(
   1792             write_result(&output, &json!({"result": "pass"})),
   1793             Err(ReproInstallError::InvalidOutput)
   1794         );
   1795     }
   1796 
   1797     #[test]
   1798     fn source_epoch_and_distinct_store_invariants_are_exact() {
   1799         let temporary = TempDir::new().expect("temporary");
   1800         let first = temporary.path().join("first");
   1801         let second = temporary.path().join("second");
   1802         fs::create_dir(&first).expect("first");
   1803         fs::create_dir(&second).expect("second");
   1804         require_distinct(&first, &second, ReproInstallError::BuildFailure).expect("distinct roots");
   1805         assert_eq!(
   1806             require_distinct(&first, &first, ReproInstallError::BuildFailure),
   1807             Err(ReproInstallError::BuildFailure)
   1808         );
   1809         let plan = sample_plan();
   1810         assert_eq!(plan.source_date_epoch, 1_700_000_000);
   1811         assert_ne!(plan.root_preimage_revision, plan.source_revision);
   1812     }
   1813 
   1814     #[cfg(unix)]
   1815     #[test]
   1816     fn full_harness_uses_two_clones_root_epoch_and_all_install_phases() {
   1817         let temporary = TempDir::new().expect("temporary");
   1818         let fixture_root = temporary.path().canonicalize().expect("fixture root");
   1819         let source = fixture_root.join("source");
   1820         let root_preimage = fixture_root.join("root-preimage");
   1821         create_repository(&source, "source.txt");
   1822         create_repository(&root_preimage, "root.txt");
   1823         let source_revision = fixture_git(&source, &["rev-parse", "HEAD"]);
   1824         let source_tree = fixture_git(&source, &["rev-parse", "HEAD^{tree}"]);
   1825         let root_revision = fixture_git(&root_preimage, &["rev-parse", "HEAD"]);
   1826         let source_date_epoch = fixture_git(
   1827             &root_preimage,
   1828             &["show", "-s", "--format=%ct", &root_revision],
   1829         )
   1830         .parse::<u64>()
   1831         .expect("root epoch");
   1832         let predecessor = fixture_root.join("predecessor");
   1833         write_file(
   1834             &predecessor.join("release.bin"),
   1835             b"exact predecessor artifact\n",
   1836         );
   1837         let predecessor = predecessor.canonicalize().expect("predecessor");
   1838         let git = program_path("git");
   1839         let adapter = fixture_root.join("adapter");
   1840         write_fixture_adapter(&adapter);
   1841 
   1842         let mut plan_value = sample_plan_value();
   1843         plan_value["source_revision"] = json!(source_revision);
   1844         plan_value["source_tree"] = json!(source_tree);
   1845         plan_value["root_preimage_revision"] = json!(root_revision);
   1846         plan_value["source_date_epoch"] = json!(source_date_epoch);
   1847         plan_value["git_executable_sha256"] =
   1848             json!(sha256_reader(File::open(&git).expect("git")).expect("git hash"));
   1849         plan_value["adapter_executable_sha256"] =
   1850             json!(sha256_bytes(&fs::read(&adapter).expect("adapter bytes")));
   1851         plan_value["build_argv"] = json!([
   1852             "build",
   1853             "{checkout}",
   1854             "{store}",
   1855             "{output}",
   1856             "{source_date_epoch}",
   1857             "{candidate_digest}",
   1858             "{target}"
   1859         ]);
   1860         plan_value["phase"] = json!(PHASE_IDS.map(|id| json!({
   1861             "id": id,
   1862             "argv": [
   1863                 "phase",
   1864                 "{phase}",
   1865                 "{install_root}",
   1866                 "{artifact_root}",
   1867                 "{artifact_set_sha256}",
   1868                 "{source_date_epoch}",
   1869                 "{candidate_digest}",
   1870                 "{target}"
   1871             ]
   1872         })));
   1873         let plan_path = fixture_root.join("plan.json");
   1874         fs::write(
   1875             &plan_path,
   1876             canonical_json_line(&plan_value).expect("plan bytes"),
   1877         )
   1878         .expect("plan");
   1879         let output = fixture_root.join("result.json");
   1880         let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
   1881         run_inner(
   1882             &workspace_root,
   1883             Arguments {
   1884                 plan: &plan_path,
   1885                 source_root: &source,
   1886                 root_preimage_root: &root_preimage,
   1887                 predecessor_artifact_root: &predecessor,
   1888                 git_executable: &git,
   1889                 adapter_executable: &adapter,
   1890                 output: &output,
   1891             },
   1892         )
   1893         .expect("full harness");
   1894         let result: Value =
   1895             serde_json::from_slice(&fs::read(&output).expect("result")).expect("result JSON");
   1896         assert_eq!(result["result"], "pass");
   1897         assert_eq!(result["build"].as_array().expect("builds").len(), 2);
   1898         assert_eq!(
   1899             result["build"][0]["artifact_inventory_sha256"],
   1900             result["build"][1]["artifact_inventory_sha256"]
   1901         );
   1902         assert_eq!(
   1903             result["install_phase"].as_array().expect("phases").len(),
   1904             PHASE_IDS.len()
   1905         );
   1906 
   1907         write_file(&source.join("untracked.txt"), b"dirty\n");
   1908         let dirty_output = fixture_root.join("dirty-result.json");
   1909         assert_eq!(
   1910             run_inner(
   1911                 &workspace_root,
   1912                 Arguments {
   1913                     plan: &plan_path,
   1914                     source_root: &source,
   1915                     root_preimage_root: &root_preimage,
   1916                     predecessor_artifact_root: &predecessor,
   1917                     git_executable: &git,
   1918                     adapter_executable: &adapter,
   1919                     output: &dirty_output,
   1920                 },
   1921             ),
   1922             Err(ReproInstallError::DirtySource)
   1923         );
   1924     }
   1925 }