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 }