consolidation.rs (86168B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 io::Read, 5 path::{Component, Path}, 6 process::Command, 7 }; 8 9 use serde::{Deserialize, Serialize}; 10 use sha2::{Digest, Sha256}; 11 12 const BASELINE_RELATIVE: &str = "contracts/consolidation/baseline.v1.toml"; 13 const STEP_MAP_RELATIVE: &str = "contracts/consolidation/handoff_steps.v1.toml"; 14 const HISTORY_RELATIVE: &str = "contracts/consolidation/history.v1.toml"; 15 const BASELINE_ID: &str = "radroots.rust.consolidation.baseline.v1"; 16 const STEP_MAP_ID: &str = "radroots.rust.consolidation.handoff-steps.v1"; 17 const ARCHITECTURE_TARGET: &str = "radroots.crates.release.v2"; 18 const HISTORY_ID: &str = "radroots.rust.consolidation.history.v1"; 19 const PACKAGE_VERSION: &str = "0.1.0-alpha"; 20 const EXPECTED_PUBLIC_PACKAGES: u16 = 19; 21 const EXPECTED_HANDOFF_STEPS: u16 = 275; 22 23 const RCLD_OWNERS: &[&str] = &[ 24 "rcld-rlc-010", 25 "rcld-rlc-020", 26 "rcld-rlc-030", 27 "rcld-rlc-040", 28 "rcld-rlc-050", 29 "rcld-rlc-060", 30 "rcld-rlc-070", 31 "rcld-rlc-080", 32 "rcld-rlc-090", 33 "rcld-rlc-100", 34 "rcld-rlc-110", 35 "rcld-rlc-120", 36 "rcld-rlc-130", 37 "rcld-rlc-140", 38 "rcld-rlc-150", 39 "rcld-rlc-160", 40 "rcld-rlc-170", 41 "rcld-rlc-180", 42 ]; 43 44 #[derive(Debug, Deserialize)] 45 #[serde(deny_unknown_fields)] 46 struct Baseline { 47 schema_version: u16, 48 baseline_id: String, 49 architecture_target: String, 50 captured_date: String, 51 public_package_version: String, 52 expected_public_packages: u16, 53 expected_handoff_steps: u16, 54 repository: Vec<Repository>, 55 surface: Vec<Surface>, 56 consumer: Vec<Consumer>, 57 additional_source: Vec<AdditionalSource>, 58 } 59 60 #[derive(Debug, Deserialize)] 61 #[serde(deny_unknown_fields)] 62 struct Repository { 63 id: String, 64 canonical_url: String, 65 commit: String, 66 tree: String, 67 branch: String, 68 clean: bool, 69 origin_synchronized: bool, 70 worktree_count: u16, 71 rust_version: String, 72 resolver: String, 73 package_version: String, 74 commands: Vec<String>, 75 } 76 77 #[derive(Debug, Deserialize)] 78 #[serde(deny_unknown_fields)] 79 struct Surface { 80 repository: String, 81 path: String, 82 tree: String, 83 authority: String, 84 } 85 86 #[derive(Debug, Deserialize)] 87 #[serde(deny_unknown_fields)] 88 struct Consumer { 89 id: String, 90 repository: String, 91 current_source: String, 92 current_revision: String, 93 target_source: String, 94 target_acquisition: String, 95 } 96 97 #[derive(Debug, Deserialize)] 98 #[serde(deny_unknown_fields)] 99 struct AdditionalSource { 100 id: String, 101 commit: String, 102 tree: String, 103 license: String, 104 disposition: String, 105 } 106 107 #[derive(Debug, Deserialize)] 108 #[serde(deny_unknown_fields)] 109 struct StepMap { 110 schema_version: u16, 111 map_id: String, 112 source_step_count: u16, 113 source_sequence: String, 114 range: Vec<StepRange>, 115 } 116 117 #[derive(Debug, Deserialize)] 118 #[serde(deny_unknown_fields)] 119 struct StepRange { 120 start: u16, 121 end: u16, 122 owners: Vec<String>, 123 disposition: String, 124 reason: String, 125 } 126 127 #[derive(Debug, Deserialize)] 128 #[serde(deny_unknown_fields)] 129 struct HistoryContract { 130 schema_version: u16, 131 history_id: String, 132 retention_locator: String, 133 hash_algorithm: String, 134 bundle_hash_algorithm: String, 135 required_commit_map_fields: Vec<String>, 136 required_verifications: Vec<String>, 137 dual_source: DualSource, 138 archive: Vec<Archive>, 139 path_map: Vec<PathMap>, 140 #[serde(default)] 141 import: Vec<ImportRecord>, 142 } 143 144 #[derive(Debug, Deserialize)] 145 #[serde(deny_unknown_fields)] 146 struct DualSource { 147 state: String, 148 canonical_owner_before_import: String, 149 canonical_owner_after_import: String, 150 emergency_fix_flow: String, 151 divergence_policy: String, 152 exit_condition: String, 153 } 154 155 #[derive(Debug, Deserialize)] 156 #[serde(deny_unknown_fields)] 157 struct Archive { 158 source_id: String, 159 kind: String, 160 frozen_commit: String, 161 artifact: String, 162 sha256: String, 163 bytes: u64, 164 source_commit_count: u64, 165 source_path_commit_count: u64, 166 rewritten_commit_count: u64, 167 topology_support_commit_count: u64, 168 omitted_empty_commit_count: u64, 169 source_object_count: u64, 170 refname: String, 171 bot_identity_scan: bool, 172 } 173 174 #[derive(Debug, Deserialize)] 175 #[serde(deny_unknown_fields)] 176 struct PathMap { 177 source_id: String, 178 source: String, 179 target: String, 180 package: String, 181 license: String, 182 disposition: String, 183 } 184 185 #[derive(Debug, Deserialize)] 186 #[serde(deny_unknown_fields)] 187 struct ImportRecord { 188 source_id: String, 189 frozen_commit: String, 190 filtered_head: String, 191 artifact: String, 192 sha256: String, 193 bytes: u64, 194 final_tree_sha256: String, 195 path_map_sha256: String, 196 } 197 198 #[derive(Clone, Debug, Eq, PartialEq)] 199 struct CommitMapEntry { 200 source: String, 201 target: Option<String>, 202 } 203 204 #[derive(Debug, Deserialize, Serialize)] 205 #[serde(deny_unknown_fields)] 206 struct ImportCommitMap { 207 schema_version: u16, 208 schema: String, 209 source_id: String, 210 frozen_commit: String, 211 filtered_head: String, 212 source_commit_count: u64, 213 source_path_commit_count: u64, 214 rewritten_commit_count: u64, 215 topology_support_commit_count: u64, 216 omitted_empty_commit_count: u64, 217 final_tree_sha256: String, 218 path_map_sha256: String, 219 verifications: Vec<String>, 220 commits: Vec<ImportCommit>, 221 } 222 223 fn verify_history_import( 224 workspace_root: &Path, 225 source_id: &str, 226 source_root: &Path, 227 filtered_root: &Path, 228 mode: &str, 229 ) -> Result<(), String> { 230 if !matches!(mode, "check" | "write") { 231 return Err("import verification mode must be check or write".to_owned()); 232 } 233 require_real_git_root(source_root, "source root")?; 234 require_real_git_root(filtered_root, "filtered root")?; 235 let history = read_toml::<HistoryContract>(workspace_root, HISTORY_RELATIVE)?; 236 validate_history(&history)?; 237 let archive = history 238 .archive 239 .iter() 240 .find(|archive| archive.source_id == source_id) 241 .ok_or_else(|| format!("unknown history source {source_id}"))?; 242 if !matches!(archive.kind.as_str(), "git_bundle" | "path_fast_export") { 243 return Err(format!( 244 "history source {source_id} has an unsupported archive kind" 245 )); 246 } 247 let path_maps = history 248 .path_map 249 .iter() 250 .filter(|path_map| path_map.source_id == source_id) 251 .collect::<Vec<_>>(); 252 if path_maps.is_empty() { 253 return Err(format!("history source {source_id} has no path map")); 254 } 255 let source_head = git_stdout(source_root, &["rev-parse", "master"])? 256 .trim() 257 .to_owned(); 258 validate_oid(&source_head, "source head")?; 259 if archive.kind == "git_bundle" && source_head != archive.frozen_commit { 260 return Err(format!( 261 "history source {source_id} is not at its frozen commit" 262 )); 263 } 264 git(source_root, &["fsck", "--full", "--strict"])?; 265 git(filtered_root, &["fsck", "--full", "--strict"])?; 266 267 let commit_map = parse_commit_map(&filtered_root.join(".git/filter-repo/commit-map"))?; 268 let source_commits = git_stdout(source_root, &["rev-list", "master"])? 269 .lines() 270 .map(str::to_owned) 271 .collect::<BTreeSet<_>>(); 272 if source_commits.len() as u64 != archive.source_commit_count 273 || commit_map.len() != source_commits.len() 274 || commit_map 275 .iter() 276 .any(|entry| !source_commits.contains(&entry.source)) 277 { 278 return Err("source history and filter-repo commit map differ".to_owned()); 279 } 280 281 let source_path_args = path_maps 282 .iter() 283 .map(|path_map| path_map.source.as_str()) 284 .collect::<Vec<_>>(); 285 let source_path_commits = rev_list_paths(source_root, &source_path_args)?; 286 let by_source = commit_map 287 .iter() 288 .map(|entry| (entry.source.as_str(), entry.target.as_deref())) 289 .collect::<BTreeMap<_, _>>(); 290 let filtered_head = git_stdout(filtered_root, &["rev-parse", "master"])? 291 .trim() 292 .to_owned(); 293 validate_oid(&filtered_head, "filtered head")?; 294 let expected_filtered_head = by_source 295 .get(source_head.as_str()) 296 .and_then(|target| *target) 297 .ok_or_else(|| "frozen source head was omitted by filtering".to_owned())?; 298 if filtered_head != expected_filtered_head { 299 return Err("filtered master does not map from the frozen source head".to_owned()); 300 } 301 302 verify_import_path_coverage( 303 source_root, 304 filtered_root, 305 &source_head, 306 &path_maps, 307 &filtered_head, 308 )?; 309 verify_import_final_tree( 310 source_root, 311 filtered_root, 312 &source_head, 313 &filtered_head, 314 &path_maps, 315 )?; 316 verify_import_context(filtered_root, &filtered_head, &path_maps)?; 317 verify_commit_identities(filtered_root, &filtered_head)?; 318 verify_import_follow_history(source_root, filtered_root, &path_maps, &by_source)?; 319 320 let mut commits = Vec::with_capacity(commit_map.len()); 321 let mut rewritten = 0_u64; 322 let mut topology = 0_u64; 323 let mut omitted = 0_u64; 324 for entry in &commit_map { 325 let source_parents = commit_parents(source_root, &entry.source)?; 326 let source_paths = changed_import_paths(source_root, &entry.source, &source_path_args)?; 327 let in_path_history = source_path_commits.contains(&entry.source); 328 let source_metadata = import_commit_metadata(source_root, &entry.source)?; 329 let source_patch = normalized_import_patch(source_root, &entry.source, &path_maps, true)?; 330 let (target_parents, target_metadata, disposition) = match entry.target.as_deref() { 331 Some(target_commit) => { 332 rewritten += 1; 333 let mut expected_parents = Vec::new(); 334 for parent in &source_parents { 335 collect_effective_parents( 336 source_root, 337 parent, 338 &by_source, 339 &mut expected_parents, 340 )?; 341 } 342 deduplicate(&mut expected_parents); 343 prune_ancestor_parents(filtered_root, &mut expected_parents)?; 344 let target_parents = commit_parents(filtered_root, target_commit)?; 345 if target_parents != expected_parents { 346 return Err(format!( 347 "mapped parent closure drifted for {}", 348 entry.source 349 )); 350 } 351 let target_metadata = import_commit_metadata(filtered_root, target_commit)?; 352 verify_import_metadata(&entry.source, &source_metadata, &target_metadata)?; 353 let target_patch = 354 normalized_import_patch(filtered_root, target_commit, &path_maps, false)?; 355 let source_tree = 356 normalized_import_tree(source_root, &entry.source, &path_maps, true)?; 357 let target_tree = 358 normalized_import_tree(filtered_root, target_commit, &path_maps, false)?; 359 if source_tree != target_tree { 360 return Err(format!("normalized tree drifted for {}", entry.source)); 361 } 362 let mut direct_target_parents = source_parents 363 .iter() 364 .filter_map(|parent| by_source.get(parent.as_str()).copied().flatten()) 365 .map(str::to_owned) 366 .collect::<Vec<_>>(); 367 let every_parent_retained = direct_target_parents.len() == source_parents.len(); 368 deduplicate(&mut direct_target_parents); 369 prune_ancestor_parents(filtered_root, &mut direct_target_parents)?; 370 if every_parent_retained 371 && direct_target_parents == target_parents 372 && source_patch != target_patch 373 { 374 return Err(format!("normalized patch drifted for {}", entry.source)); 375 } 376 let disposition = if in_path_history { 377 "retained" 378 } else { 379 topology += 1; 380 "topology_support" 381 }; 382 (target_parents, Some(target_metadata), disposition) 383 } 384 None => { 385 let disposition = if in_path_history { 386 omitted += 1; 387 "omitted_empty" 388 } else { 389 "out_of_scope" 390 }; 391 (Vec::new(), None, disposition) 392 } 393 }; 394 commits.push(ImportCommit { 395 source_commit: if archive.kind == "path_fast_export" { 396 archive.frozen_commit.clone() 397 } else { 398 entry.source.clone() 399 }, 400 target_commit: entry.target.clone(), 401 source_parents, 402 target_parents, 403 source_subject: source_metadata.subject, 404 target_subject: target_metadata 405 .as_ref() 406 .map(|metadata| metadata.subject.clone()), 407 source_author: source_metadata.author, 408 target_author: target_metadata 409 .as_ref() 410 .map(|metadata| metadata.author.clone()), 411 source_author_time: source_metadata.author_time, 412 target_author_time: target_metadata 413 .as_ref() 414 .map(|metadata| metadata.author_time.clone()), 415 source_committer_time: source_metadata.committer_time, 416 target_committer_time: target_metadata 417 .as_ref() 418 .map(|metadata| metadata.committer_time.clone()), 419 source_paths, 420 normalized_patch_sha256: sha256_bytes(source_patch.as_bytes()), 421 empty_commit_disposition: disposition.to_owned(), 422 }); 423 } 424 if source_path_commits.len() as u64 != archive.source_path_commit_count 425 || rewritten != archive.rewritten_commit_count 426 || topology != archive.topology_support_commit_count 427 || omitted != archive.omitted_empty_commit_count 428 { 429 return Err(format!( 430 "history counts drifted: path={}, rewritten={rewritten}, topology={topology}, omitted={omitted}", 431 source_path_commits.len() 432 )); 433 } 434 435 let path_map_bytes = path_maps 436 .iter() 437 .map(|path_map| { 438 format!( 439 "{}\0{}\0{}\n", 440 path_map.source, path_map.target, path_map.license 441 ) 442 }) 443 .collect::<String>(); 444 let final_tree = normalized_import_tree(filtered_root, &filtered_head, &path_maps, false)?; 445 let artifact = ImportCommitMap { 446 schema_version: 1, 447 schema: "radroots.history-import.commit-map.v1".to_owned(), 448 source_id: source_id.to_owned(), 449 frozen_commit: archive.frozen_commit.clone(), 450 filtered_head, 451 source_commit_count: archive.source_commit_count, 452 source_path_commit_count: archive.source_path_commit_count, 453 rewritten_commit_count: archive.rewritten_commit_count, 454 topology_support_commit_count: archive.topology_support_commit_count, 455 omitted_empty_commit_count: archive.omitted_empty_commit_count, 456 final_tree_sha256: sha256_bytes(final_tree.as_bytes()), 457 path_map_sha256: sha256_bytes(path_map_bytes.as_bytes()), 458 verifications: history.required_verifications.clone(), 459 commits, 460 }; 461 let mut bytes = serde_json::to_vec_pretty(&artifact) 462 .map_err(|error| format!("serialize history import artifact: {error}"))?; 463 bytes.push(b'\n'); 464 let output = workspace_root.join(format!( 465 "contracts/consolidation/imports/{source_id}.commit-map.v1.json" 466 )); 467 match mode { 468 "write" => crate::build_control::atomic_write(&output, &bytes), 469 "check" => { 470 let current = 471 fs::read(&output).map_err(|error| format!("read {}: {error}", output.display()))?; 472 if current == bytes { 473 Ok(()) 474 } else { 475 Err(format!( 476 "history import artifact {} is stale", 477 output.display() 478 )) 479 } 480 } 481 _ => unreachable!("mode validated"), 482 } 483 } 484 485 fn prune_ancestor_parents(root: &Path, parents: &mut Vec<String>) -> Result<(), String> { 486 let original = parents.clone(); 487 let mut keep = Vec::new(); 488 for parent in &original { 489 let mut redundant = false; 490 for candidate in &original { 491 if parent == candidate { 492 continue; 493 } 494 let status = Command::new("git") 495 .args(["merge-base", "--is-ancestor", parent, candidate]) 496 .current_dir(root) 497 .status() 498 .map_err(|error| format!("run git merge-base --is-ancestor: {error}"))?; 499 match status.code() { 500 Some(0) => { 501 redundant = true; 502 break; 503 } 504 Some(1) => {} 505 _ => return Err("git merge-base --is-ancestor failed".to_owned()), 506 } 507 } 508 if !redundant { 509 keep.push(parent.clone()); 510 } 511 } 512 *parents = keep; 513 Ok(()) 514 } 515 516 #[derive(Debug)] 517 struct ImportMetadata { 518 author: String, 519 author_time: String, 520 committer_time: String, 521 subject: String, 522 } 523 524 fn require_real_git_root(path: &Path, label: &str) -> Result<(), String> { 525 if !path.is_absolute() { 526 return Err(format!("{label} must be absolute")); 527 } 528 let metadata = fs::symlink_metadata(path) 529 .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?; 530 if metadata.file_type().is_symlink() || !metadata.is_dir() { 531 return Err(format!("{label} must be a real directory")); 532 } 533 let git_dir = git_stdout(path, &["rev-parse", "--absolute-git-dir"])?; 534 let git_dir = Path::new(git_dir.trim()); 535 if !git_dir.is_absolute() { 536 return Err(format!("{label} Git directory must be absolute")); 537 } 538 let metadata = fs::symlink_metadata(git_dir) 539 .map_err(|error| format!("inspect {label} Git directory: {error}"))?; 540 if metadata.file_type().is_symlink() || !metadata.is_dir() { 541 return Err(format!("{label} must contain a real Git directory")); 542 } 543 Ok(()) 544 } 545 546 fn rev_list_paths(root: &Path, paths: &[&str]) -> Result<BTreeSet<String>, String> { 547 let mut args = vec!["rev-list", "--full-history", "master", "--"]; 548 args.extend_from_slice(paths); 549 Ok(git_stdout(root, &args)? 550 .lines() 551 .map(str::to_owned) 552 .collect()) 553 } 554 555 fn changed_import_paths(root: &Path, commit: &str, paths: &[&str]) -> Result<Vec<String>, String> { 556 let mut args = vec![ 557 "diff-tree", 558 "--root", 559 "-m", 560 "-r", 561 "--no-commit-id", 562 "--name-only", 563 commit, 564 "--", 565 ]; 566 args.extend_from_slice(paths); 567 let mut changed = git_stdout(root, &args)? 568 .lines() 569 .map(str::to_owned) 570 .collect::<Vec<_>>(); 571 changed.sort(); 572 changed.dedup(); 573 Ok(changed) 574 } 575 576 fn import_commit_metadata(root: &Path, commit: &str) -> Result<ImportMetadata, String> { 577 let raw = git_stdout( 578 root, 579 &[ 580 "show", 581 "-s", 582 "--format=%an%x00%ae%x00%aI%x00%cI%x00%s", 583 commit, 584 ], 585 )?; 586 let fields = raw.trim_end().split('\0').collect::<Vec<_>>(); 587 if fields.len() != 5 { 588 return Err(format!("commit metadata cardinality drifted for {commit}")); 589 } 590 Ok(ImportMetadata { 591 author: format!("{} <{}>", fields[0], fields[1]), 592 author_time: fields[2].to_owned(), 593 committer_time: fields[3].to_owned(), 594 subject: fields[4].to_owned(), 595 }) 596 } 597 598 fn verify_import_metadata( 599 source_commit: &str, 600 source: &ImportMetadata, 601 target: &ImportMetadata, 602 ) -> Result<(), String> { 603 if source.author != target.author 604 || source.author_time != target.author_time 605 || source.committer_time != target.committer_time 606 || !message_is_preserved(&source.subject, &target.subject) 607 { 608 return Err(format!( 609 "attribution or message drifted for {source_commit}" 610 )); 611 } 612 Ok(()) 613 } 614 615 fn normalized_import_patch( 616 root: &Path, 617 commit: &str, 618 path_maps: &[&PathMap], 619 source_side: bool, 620 ) -> Result<String, String> { 621 let mut normalized = String::new(); 622 for path_map in path_maps { 623 let path = if source_side { 624 path_map.source.as_str() 625 } else { 626 path_map.target.as_str() 627 }; 628 let patch = git_stdout( 629 root, 630 &[ 631 "-c", 632 "core.quotePath=false", 633 "diff-tree", 634 "--root", 635 "-m", 636 "-r", 637 "--binary", 638 "--full-index", 639 "--no-commit-id", 640 commit, 641 "--", 642 path, 643 ], 644 )?; 645 normalized.push_str(&normalize_import_patch_paths(patch, path_maps, source_side)); 646 } 647 Ok(normalized) 648 } 649 650 fn normalize_import_patch_paths( 651 value: String, 652 path_maps: &[&PathMap], 653 source_side: bool, 654 ) -> String { 655 value 656 .split_inclusive('\n') 657 .map(|line| { 658 if line.starts_with("diff --git ") 659 || line.starts_with("--- ") 660 || line.starts_with("+++ ") 661 || line.starts_with("rename from ") 662 || line.starts_with("rename to ") 663 || line.starts_with("copy from ") 664 || line.starts_with("copy to ") 665 || line.starts_with("Binary files ") 666 { 667 normalize_import_paths(line.to_owned(), path_maps, source_side) 668 } else { 669 line.to_owned() 670 } 671 }) 672 .collect() 673 } 674 675 fn normalize_import_paths(mut value: String, path_maps: &[&PathMap], source_side: bool) -> String { 676 let mut replacements = path_maps 677 .iter() 678 .enumerate() 679 .map(|(index, path_map)| { 680 let path = if source_side { 681 path_map.source.as_str() 682 } else { 683 path_map.target.as_str() 684 }; 685 (path, format!("__IMPORT__/{index:02}")) 686 }) 687 .collect::<Vec<_>>(); 688 replacements.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); 689 for (path, replacement) in replacements { 690 value = value.replace(path, &replacement); 691 } 692 value 693 } 694 695 fn normalized_import_tree( 696 root: &Path, 697 commit: &str, 698 path_maps: &[&PathMap], 699 source_side: bool, 700 ) -> Result<String, String> { 701 let mut args = vec!["ls-tree", "-r", "--full-tree", commit, "--"]; 702 args.extend(path_maps.iter().map(|path_map| { 703 if source_side { 704 path_map.source.as_str() 705 } else { 706 path_map.target.as_str() 707 } 708 })); 709 let normalized = normalize_import_paths(git_stdout(root, &args)?, path_maps, source_side); 710 let mut lines = normalized.lines().collect::<Vec<_>>(); 711 lines.sort_unstable(); 712 Ok(format!("{}\n", lines.join("\n"))) 713 } 714 715 fn verify_import_final_tree( 716 source_root: &Path, 717 filtered_root: &Path, 718 source_commit: &str, 719 target_commit: &str, 720 path_maps: &[&PathMap], 721 ) -> Result<(), String> { 722 let source = normalized_import_tree(source_root, source_commit, path_maps, true)?; 723 let target = normalized_import_tree(filtered_root, target_commit, path_maps, false)?; 724 if source == target { 725 Ok(()) 726 } else { 727 Err("filtered import final tree drifted".to_owned()) 728 } 729 } 730 731 fn verify_import_path_coverage( 732 source_root: &Path, 733 filtered_root: &Path, 734 source_commit: &str, 735 path_maps: &[&PathMap], 736 filtered_head: &str, 737 ) -> Result<(), String> { 738 for path_map in path_maps { 739 git( 740 source_root, 741 &[ 742 "cat-file", 743 "-e", 744 &format!("{source_commit}:{}", path_map.source), 745 ], 746 )?; 747 git( 748 filtered_root, 749 &[ 750 "cat-file", 751 "-e", 752 &format!("{filtered_head}:{}", path_map.target), 753 ], 754 )?; 755 } 756 Ok(()) 757 } 758 759 fn verify_import_context( 760 filtered_root: &Path, 761 filtered_head: &str, 762 path_maps: &[&PathMap], 763 ) -> Result<(), String> { 764 let paths = git_stdout( 765 filtered_root, 766 &["ls-tree", "-r", "--name-only", filtered_head], 767 )?; 768 for path in paths.lines() { 769 let lower = path.to_ascii_lowercase(); 770 if !path_maps.iter().any(|path_map| { 771 path == path_map.target || path.starts_with(&format!("{}/", path_map.target)) 772 }) || path.split('/').any(|segment| segment == ".github") 773 || matches!(path.rsplit('/').next(), Some("AGENTS.md" | "CLAUDE.md")) 774 || lower.ends_with(".pem") 775 || lower.ends_with(".key") 776 || lower.ends_with("/.env") 777 { 778 return Err(format!("context firewall rejected {path}")); 779 } 780 let contents = git_bytes(filtered_root, &["show", &format!("{filtered_head}:{path}")])?; 781 let text = String::from_utf8_lossy(&contents); 782 let lower_contents = text.to_ascii_lowercase(); 783 let license_metadata_stripped = lower_contents 784 .replace("license = \"gpl-3.0-only\"", "") 785 .replace("license = \"gpl-3.0-or-later\"", "") 786 .replace("license = \"mit or apache-2.0\"", "") 787 .replace("license = \"mpl-2.0\"", ""); 788 if text.contains("PRIVATE KEY-----") 789 || text.contains("ghp_") 790 || lower_contents.contains("github-actions[bot]") 791 || license_metadata_stripped.contains("gpl-3.0") 792 { 793 return Err(format!( 794 "secret, bot, or license content rejected in {path}" 795 )); 796 } 797 } 798 Ok(()) 799 } 800 801 fn verify_import_follow_history( 802 source_root: &Path, 803 filtered_root: &Path, 804 path_maps: &[&PathMap], 805 by_source: &BTreeMap<&str, Option<&str>>, 806 ) -> Result<(), String> { 807 let mapped_targets = by_source 808 .values() 809 .filter_map(|target| *target) 810 .collect::<BTreeSet<_>>(); 811 for path_map in path_maps { 812 let target_paths = git_stdout( 813 filtered_root, 814 &[ 815 "ls-tree", 816 "-r", 817 "--name-only", 818 "master", 819 "--", 820 &path_map.target, 821 ], 822 )?; 823 for target_path in target_paths.lines() { 824 let suffix = target_path 825 .strip_prefix(&path_map.target) 826 .ok_or_else(|| "target path escaped its import root".to_owned())?; 827 let source_path = format!("{}{}", path_map.source, suffix); 828 let source_log_has_mapped_commit = git_stdout( 829 source_root, 830 &["log", "--follow", "--format=%H", "--", &source_path], 831 )? 832 .lines() 833 .any(|commit| by_source.get(commit).copied().flatten().is_some()); 834 let target_log = git_stdout( 835 filtered_root, 836 &["log", "--follow", "--format=%H", "--", target_path], 837 )? 838 .lines() 839 .map(str::to_owned) 840 .collect::<Vec<_>>(); 841 if !source_log_has_mapped_commit 842 || target_log.is_empty() 843 || target_log 844 .iter() 845 .any(|commit| !mapped_targets.contains(commit.as_str())) 846 { 847 return Err(format!("git log --follow drifted for {source_path}")); 848 } 849 } 850 } 851 Ok(()) 852 } 853 854 fn sha256_bytes(bytes: &[u8]) -> String { 855 let mut hasher = Sha256::new(); 856 hasher.update(bytes); 857 format!("{:x}", hasher.finalize()) 858 } 859 860 #[derive(Debug, Deserialize, Serialize)] 861 #[serde(deny_unknown_fields)] 862 struct ImportCommit { 863 source_commit: String, 864 target_commit: Option<String>, 865 source_parents: Vec<String>, 866 target_parents: Vec<String>, 867 source_subject: String, 868 target_subject: Option<String>, 869 source_author: String, 870 target_author: Option<String>, 871 source_author_time: String, 872 target_author_time: Option<String>, 873 source_committer_time: String, 874 target_committer_time: Option<String>, 875 source_paths: Vec<String>, 876 normalized_patch_sha256: String, 877 empty_commit_disposition: String, 878 } 879 880 pub fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { 881 match args { 882 [command] if command == "baseline" => validate_baseline_contracts(workspace_root), 883 [command] if command == "history" => validate_history_contract(workspace_root, None), 884 [command] if command == "history-rehearsal" => run_history_rehearsal(), 885 [command, source_flag, source_id, source_root_flag, source_root, filtered_root_flag, filtered_root, mode_flag, mode] 886 if command == "import-verify" 887 && source_flag == "--source" 888 && source_root_flag == "--source-root" 889 && filtered_root_flag == "--filtered-root" 890 && mode_flag == "--mode" => 891 { 892 verify_history_import( 893 workspace_root, 894 source_id, 895 Path::new(source_root), 896 Path::new(filtered_root), 897 mode, 898 ) 899 } 900 [command, flag, archive_root] if command == "history" && flag == "--archive-root" => { 901 validate_history_contract(workspace_root, Some(Path::new(archive_root))) 902 } 903 _ => Err( 904 "consolidation accepts baseline, history [--archive-root <absolute-directory>], history-rehearsal, or import-verify --source <id> --source-root <absolute-directory> --filtered-root <absolute-directory> --mode <check|write>" 905 .to_owned(), 906 ), 907 } 908 } 909 910 pub fn validate_baseline_contracts(workspace_root: &Path) -> Result<(), String> { 911 let baseline = read_toml::<Baseline>(workspace_root, BASELINE_RELATIVE)?; 912 let step_map = read_toml::<StepMap>(workspace_root, STEP_MAP_RELATIVE)?; 913 validate_baseline(&baseline)?; 914 validate_step_map(&step_map) 915 } 916 917 fn validate_history_contract( 918 workspace_root: &Path, 919 archive_root: Option<&Path>, 920 ) -> Result<(), String> { 921 let history = read_toml::<HistoryContract>(workspace_root, HISTORY_RELATIVE)?; 922 validate_history(&history)?; 923 validate_retired_import_targets(workspace_root, &history.path_map)?; 924 validate_import_records(workspace_root, &history)?; 925 if let Some(archive_root) = archive_root { 926 validate_archives(&history, archive_root)?; 927 } 928 Ok(()) 929 } 930 931 fn validate_retired_import_targets( 932 workspace_root: &Path, 933 path_maps: &[PathMap], 934 ) -> Result<(), String> { 935 for path_map in path_maps 936 .iter() 937 .filter(|path_map| path_map.disposition == "import_unique_behavior_then_retire") 938 { 939 let target = workspace_root.join(&path_map.target); 940 match fs::symlink_metadata(&target) { 941 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 942 Err(error) => { 943 return Err(format!( 944 "inspect retired import target {}: {error}", 945 path_map.target 946 )); 947 } 948 Ok(_) => { 949 return Err(format!( 950 "retired import target remains in the active tree: {}", 951 path_map.target 952 )); 953 } 954 } 955 } 956 Ok(()) 957 } 958 959 fn validate_import_records(workspace_root: &Path, history: &HistoryContract) -> Result<(), String> { 960 let mut sources = BTreeSet::new(); 961 for record in &history.import { 962 if !sources.insert(record.source_id.as_str()) { 963 return Err(format!( 964 "duplicate history import source {}", 965 record.source_id 966 )); 967 } 968 let archive = history 969 .archive 970 .iter() 971 .find(|archive| archive.source_id == record.source_id) 972 .ok_or_else(|| format!("unknown history import source {}", record.source_id))?; 973 if record.frozen_commit != archive.frozen_commit { 974 return Err(format!( 975 "history import {} frozen commit drifted", 976 record.source_id 977 )); 978 } 979 validate_oid(&record.filtered_head, "filtered import head")?; 980 validate_artifact_name(&record.artifact)?; 981 validate_sha256(&record.sha256, "commit-map artifact digest")?; 982 validate_sha256(&record.final_tree_sha256, "import final-tree digest")?; 983 validate_sha256(&record.path_map_sha256, "import path-map digest")?; 984 let path = workspace_root 985 .join("contracts/consolidation/imports") 986 .join(&record.artifact); 987 let metadata = fs::symlink_metadata(&path) 988 .map_err(|error| format!("inspect {}: {error}", path.display()))?; 989 if metadata.file_type().is_symlink() 990 || !metadata.is_file() 991 || metadata.len() != record.bytes 992 { 993 return Err(format!( 994 "history import artifact {} metadata drifted", 995 path.display() 996 )); 997 } 998 let bytes = fs::read(&path) 999 .map_err(|error| format!("read history import artifact {}: {error}", path.display()))?; 1000 if sha256_bytes(&bytes) != record.sha256 { 1001 return Err(format!( 1002 "history import artifact {} digest drifted", 1003 path.display() 1004 )); 1005 } 1006 let artifact = serde_json::from_slice::<ImportCommitMap>(&bytes).map_err(|error| { 1007 format!("parse history import artifact {}: {error}", path.display()) 1008 })?; 1009 if artifact.schema_version != 1 1010 || artifact.schema != "radroots.history-import.commit-map.v1" 1011 || artifact.source_id != record.source_id 1012 || artifact.frozen_commit != record.frozen_commit 1013 || artifact.filtered_head != record.filtered_head 1014 || artifact.final_tree_sha256 != record.final_tree_sha256 1015 || artifact.path_map_sha256 != record.path_map_sha256 1016 || artifact.source_commit_count != archive.source_commit_count 1017 || artifact.source_path_commit_count != archive.source_path_commit_count 1018 || artifact.rewritten_commit_count != archive.rewritten_commit_count 1019 || artifact.topology_support_commit_count != archive.topology_support_commit_count 1020 || artifact.omitted_empty_commit_count != archive.omitted_empty_commit_count 1021 || artifact.commits.len() as u64 != archive.source_commit_count 1022 || to_unique_set(&artifact.verifications, "import verification")? 1023 != to_unique_set(&history.required_verifications, "required verification")? 1024 { 1025 return Err(format!( 1026 "history import artifact {} contract drifted", 1027 record.source_id 1028 )); 1029 } 1030 } 1031 Ok(()) 1032 } 1033 1034 fn read_toml<T: for<'de> Deserialize<'de>>( 1035 workspace_root: &Path, 1036 relative: &str, 1037 ) -> Result<T, String> { 1038 let path = workspace_root.join(relative); 1039 let raw = 1040 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 1041 toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) 1042 } 1043 1044 fn validate_baseline(baseline: &Baseline) -> Result<(), String> { 1045 if baseline.schema_version != 1 1046 || baseline.baseline_id != BASELINE_ID 1047 || baseline.architecture_target != ARCHITECTURE_TARGET 1048 || baseline.public_package_version != PACKAGE_VERSION 1049 || baseline.expected_public_packages != EXPECTED_PUBLIC_PACKAGES 1050 || baseline.expected_handoff_steps != EXPECTED_HANDOFF_STEPS 1051 { 1052 return Err("consolidation baseline identity or cardinality drifted".to_owned()); 1053 } 1054 validate_date(&baseline.captured_date)?; 1055 1056 let expected_repositories = BTreeSet::from(["app_rt", "lib", "sdk", "studio_app"]); 1057 let mut repositories = BTreeMap::new(); 1058 for repository in &baseline.repository { 1059 if repositories 1060 .insert(repository.id.as_str(), repository) 1061 .is_some() 1062 { 1063 return Err(format!("duplicate repository id {}", repository.id)); 1064 } 1065 validate_identifier(&repository.id, "repository id")?; 1066 if repository.canonical_url != format!("https://github.com/radrootslabs/{}", repository.id) 1067 { 1068 return Err(format!( 1069 "repository {} has a noncanonical URL", 1070 repository.id 1071 )); 1072 } 1073 validate_oid(&repository.commit, "repository commit")?; 1074 validate_oid(&repository.tree, "repository tree")?; 1075 if repository.branch != "master" 1076 || !repository.clean 1077 || !repository.origin_synchronized 1078 || repository.worktree_count != 1 1079 || repository.rust_version != "1.97.1" 1080 || !matches!(repository.resolver.as_str(), "2" | "3") 1081 || repository.commands.is_empty() 1082 { 1083 return Err(format!( 1084 "repository {} baseline is not frozen", 1085 repository.id 1086 )); 1087 } 1088 if repository 1089 .commands 1090 .iter() 1091 .any(|command| command.trim().is_empty()) 1092 { 1093 return Err(format!("repository {} has an empty command", repository.id)); 1094 } 1095 } 1096 if repositories.keys().copied().collect::<BTreeSet<_>>() != expected_repositories { 1097 return Err( 1098 "consolidation baseline must contain exactly lib, sdk, app_rt, and studio_app" 1099 .to_owned(), 1100 ); 1101 } 1102 if repositories["lib"].resolver != "3" 1103 || repositories["sdk"].resolver != "3" 1104 || repositories["studio_app"].resolver != "3" 1105 || repositories["app_rt"].resolver != "2" 1106 { 1107 return Err("reviewed resolver baseline drifted".to_owned()); 1108 } 1109 if repositories["lib"].package_version != PACKAGE_VERSION 1110 || repositories["sdk"].package_version != PACKAGE_VERSION 1111 || repositories["studio_app"].package_version != PACKAGE_VERSION 1112 || repositories["app_rt"].package_version != "0.1.0-alpha.1" 1113 { 1114 return Err("reviewed package version baseline drifted".to_owned()); 1115 } 1116 1117 let mut surfaces = BTreeSet::new(); 1118 let mut authorities = BTreeSet::new(); 1119 for surface in &baseline.surface { 1120 if !repositories.contains_key(surface.repository.as_str()) { 1121 return Err(format!("unknown surface repository {}", surface.repository)); 1122 } 1123 validate_relative_path(&surface.path)?; 1124 validate_oid(&surface.tree, "surface tree")?; 1125 validate_identifier(&surface.authority, "surface authority")?; 1126 if !surfaces.insert((surface.repository.as_str(), surface.path.as_str())) { 1127 return Err(format!( 1128 "duplicate surface {}/{}", 1129 surface.repository, surface.path 1130 )); 1131 } 1132 if !authorities.insert(surface.authority.as_str()) { 1133 return Err(format!("duplicate surface authority {}", surface.authority)); 1134 } 1135 } 1136 for repository in expected_repositories { 1137 if !surfaces 1138 .iter() 1139 .any(|(candidate, _)| *candidate == repository) 1140 { 1141 return Err(format!( 1142 "repository {repository} has no compatibility surface" 1143 )); 1144 } 1145 } 1146 1147 let expected_consumers = BTreeSet::from([ 1148 "cli", 1149 "integration_parent", 1150 "ios_app", 1151 "mobile_runtime", 1152 "myc", 1153 "radrootsd", 1154 "rhi", 1155 "sdk_product", 1156 "studio_product", 1157 "event_indexer", 1158 ]); 1159 let mut consumers = BTreeSet::new(); 1160 for consumer in &baseline.consumer { 1161 validate_identifier(&consumer.id, "consumer id")?; 1162 if !consumers.insert(consumer.id.as_str()) { 1163 return Err(format!("duplicate consumer id {}", consumer.id)); 1164 } 1165 if consumer.repository.trim().is_empty() 1166 || consumer.current_source.trim().is_empty() 1167 || consumer.target_source != "lib" 1168 { 1169 return Err(format!("consumer {} has incomplete ownership", consumer.id)); 1170 } 1171 validate_oid(&consumer.current_revision, "consumer revision")?; 1172 if !matches!( 1173 consumer.target_acquisition.as_str(), 1174 "exact_git_rev" | "exact_registered_gitlink" 1175 ) { 1176 return Err(format!( 1177 "consumer {} has invalid target acquisition", 1178 consumer.id 1179 )); 1180 } 1181 } 1182 if consumers != expected_consumers { 1183 return Err("consumer census is incomplete".to_owned()); 1184 } 1185 1186 if baseline.additional_source.len() != 1 { 1187 return Err("exactly one additional Studio source is required".to_owned()); 1188 } 1189 let additional = &baseline.additional_source[0]; 1190 validate_identifier(&additional.id, "additional source id")?; 1191 validate_oid(&additional.commit, "additional source commit")?; 1192 validate_oid(&additional.tree, "additional source tree")?; 1193 if additional.id != "studio_mpl_legacy_core" 1194 || additional.license != "MPL-2.0" 1195 || additional.disposition != "import_unique_behavior_then_zero_logic_capsule" 1196 { 1197 return Err("additional Studio source disposition drifted".to_owned()); 1198 } 1199 Ok(()) 1200 } 1201 1202 fn validate_step_map(step_map: &StepMap) -> Result<(), String> { 1203 if step_map.schema_version != 1 1204 || step_map.map_id != STEP_MAP_ID 1205 || step_map.source_step_count != EXPECTED_HANDOFF_STEPS 1206 || !step_map 1207 .source_sequence 1208 .ends_with("implementation/COMMIT_SEQUENCE.md") 1209 { 1210 return Err("handoff step map identity drifted".to_owned()); 1211 } 1212 let allowed_owners = RCLD_OWNERS.iter().copied().collect::<BTreeSet<_>>(); 1213 let allowed_dispositions = BTreeSet::from(["already_satisfied", "execute", "reassigned"]); 1214 let mut next = 1_u16; 1215 for range in &step_map.range { 1216 if range.start != next || range.end < range.start || range.end > EXPECTED_HANDOFF_STEPS { 1217 return Err(format!( 1218 "handoff step range {}-{} is overlapping, gapped, or invalid; expected {}", 1219 range.start, range.end, next 1220 )); 1221 } 1222 if range.owners.is_empty() 1223 || range 1224 .owners 1225 .iter() 1226 .any(|owner| !allowed_owners.contains(owner.as_str())) 1227 { 1228 return Err(format!( 1229 "handoff step range {}-{} has an invalid owner", 1230 range.start, range.end 1231 )); 1232 } 1233 if !allowed_dispositions.contains(range.disposition.as_str()) 1234 || range.reason.trim().is_empty() 1235 { 1236 return Err(format!( 1237 "handoff step range {}-{} has an invalid disposition", 1238 range.start, range.end 1239 )); 1240 } 1241 next = range 1242 .end 1243 .checked_add(1) 1244 .ok_or_else(|| "handoff step range overflow".to_owned())?; 1245 } 1246 if next != EXPECTED_HANDOFF_STEPS + 1 { 1247 return Err(format!( 1248 "handoff step map ends at {}, expected {}", 1249 next.saturating_sub(1), 1250 EXPECTED_HANDOFF_STEPS 1251 )); 1252 } 1253 Ok(()) 1254 } 1255 1256 fn validate_history(history: &HistoryContract) -> Result<(), String> { 1257 if history.schema_version != 1 1258 || history.history_id != HISTORY_ID 1259 || history.retention_locator != "external://radroots-rust-consolidation-v1-20260805" 1260 || history.hash_algorithm != "sha256" 1261 || history.bundle_hash_algorithm != "sha1" 1262 { 1263 return Err("history preservation identity drifted".to_owned()); 1264 } 1265 1266 let expected_commit_map_fields = BTreeSet::from([ 1267 "empty_commit_disposition", 1268 "normalized_patch_sha256", 1269 "source_author", 1270 "source_author_time", 1271 "source_commit", 1272 "source_committer_time", 1273 "source_parents", 1274 "source_paths", 1275 "source_subject", 1276 "target_author", 1277 "target_author_time", 1278 "target_commit", 1279 "target_committer_time", 1280 "target_parents", 1281 "target_subject", 1282 ]); 1283 if to_unique_set(&history.required_commit_map_fields, "commit map field")? 1284 != expected_commit_map_fields 1285 { 1286 return Err("commit map requirements drifted".to_owned()); 1287 } 1288 let expected_verifications = BTreeSet::from([ 1289 "archive_restore", 1290 "authorship", 1291 "bot_identity_scan", 1292 "commit_count", 1293 "context_firewall", 1294 "final_tree_digest", 1295 "git_fsck", 1296 "git_log_follow", 1297 "github_workflow_exclusion", 1298 "license_scan", 1299 "mapped_parent_closure", 1300 "message_scope_only", 1301 "normalized_patch_equivalence", 1302 "path_coverage", 1303 "secret_scan", 1304 "timestamps", 1305 ]); 1306 if to_unique_set(&history.required_verifications, "verification")? != expected_verifications { 1307 return Err("history verification requirements drifted".to_owned()); 1308 } 1309 let dual_source = &history.dual_source; 1310 if dual_source.state != "pre_import" 1311 || dual_source.canonical_owner_before_import != "frozen_donor_commit" 1312 || dual_source.canonical_owner_after_import != "verified_lib_import_merge" 1313 || dual_source.divergence_policy != "forbidden" 1314 || dual_source.emergency_fix_flow.trim().is_empty() 1315 || dual_source.exit_condition.trim().is_empty() 1316 { 1317 return Err("dual-source control drifted".to_owned()); 1318 } 1319 1320 let expected_sources = 1321 BTreeSet::from(["app_rt", "sdk", "studio_app", "studio_mpl_legacy_core"]); 1322 let mut archives = BTreeMap::new(); 1323 let mut artifact_names = BTreeSet::new(); 1324 for archive in &history.archive { 1325 validate_identifier(&archive.source_id, "archive source id")?; 1326 if archives 1327 .insert(archive.source_id.as_str(), archive) 1328 .is_some() 1329 { 1330 return Err(format!("duplicate archive source {}", archive.source_id)); 1331 } 1332 validate_oid(&archive.frozen_commit, "archive frozen commit")?; 1333 validate_sha256(&archive.sha256, "archive digest")?; 1334 validate_artifact_name(&archive.artifact)?; 1335 if !artifact_names.insert(archive.artifact.as_str()) { 1336 return Err(format!("duplicate archive artifact {}", archive.artifact)); 1337 } 1338 if !matches!(archive.kind.as_str(), "git_bundle" | "path_fast_export") 1339 || archive.bytes == 0 1340 || archive.source_commit_count == 0 1341 || archive.source_path_commit_count == 0 1342 || archive.source_path_commit_count > archive.source_commit_count 1343 || archive.rewritten_commit_count == 0 1344 || archive.rewritten_commit_count + archive.omitted_empty_commit_count 1345 != archive.source_path_commit_count + archive.topology_support_commit_count 1346 || archive.source_object_count == 0 1347 || archive.refname != "refs/heads/master" 1348 || !archive.bot_identity_scan 1349 { 1350 return Err(format!("archive {} is incomplete", archive.source_id)); 1351 } 1352 } 1353 if archives.keys().copied().collect::<BTreeSet<_>>() != expected_sources { 1354 return Err("history archive inventory is incomplete".to_owned()); 1355 } 1356 if archives["studio_mpl_legacy_core"].kind != "path_fast_export" 1357 || archives 1358 .iter() 1359 .filter(|(id, archive)| { 1360 **id != "studio_mpl_legacy_core" && archive.kind == "git_bundle" 1361 }) 1362 .count() 1363 != 3 1364 { 1365 return Err("history archive kinds drifted".to_owned()); 1366 } 1367 1368 let allowed_licenses = BTreeSet::from([ 1369 "GPL-3.0-only", 1370 "GPL-3.0-or-later", 1371 "MIT OR Apache-2.0", 1372 "MPL-2.0", 1373 ]); 1374 let allowed_dispositions = BTreeSet::from([ 1375 "import_unique_behavior_then_retire", 1376 "merge_then_retire", 1377 "retain", 1378 "retain_private", 1379 ]); 1380 let mut source_paths = BTreeSet::new(); 1381 let mut target_paths = BTreeSet::new(); 1382 let mut package_sources = BTreeSet::new(); 1383 for path_map in &history.path_map { 1384 if !archives.contains_key(path_map.source_id.as_str()) { 1385 return Err(format!("unknown path-map source {}", path_map.source_id)); 1386 } 1387 validate_relative_path(&path_map.source)?; 1388 validate_relative_path(&path_map.target)?; 1389 validate_identifier(&path_map.package, "path-map package")?; 1390 if !allowed_licenses.contains(path_map.license.as_str()) 1391 || !allowed_dispositions.contains(path_map.disposition.as_str()) 1392 { 1393 return Err(format!( 1394 "path map {}/{} has invalid license or disposition", 1395 path_map.source_id, path_map.source 1396 )); 1397 } 1398 if !source_paths.insert((path_map.source_id.as_str(), path_map.source.as_str())) { 1399 return Err(format!( 1400 "duplicate source path {}/{}", 1401 path_map.source_id, path_map.source 1402 )); 1403 } 1404 if !target_paths.insert(path_map.target.as_str()) { 1405 return Err(format!("duplicate target path {}", path_map.target)); 1406 } 1407 if !package_sources.insert((path_map.source_id.as_str(), path_map.package.as_str())) { 1408 return Err(format!( 1409 "duplicate package {} in source {}", 1410 path_map.package, path_map.source_id 1411 )); 1412 } 1413 } 1414 for source in expected_sources { 1415 if !source_paths 1416 .iter() 1417 .any(|(candidate, _)| *candidate == source) 1418 { 1419 return Err(format!("source {source} has no path map")); 1420 } 1421 } 1422 Ok(()) 1423 } 1424 1425 fn validate_archives(history: &HistoryContract, archive_root: &Path) -> Result<(), String> { 1426 if !archive_root.is_absolute() { 1427 return Err("archive root must be absolute".to_owned()); 1428 } 1429 let root_metadata = fs::symlink_metadata(archive_root) 1430 .map_err(|error| format!("inspect archive root {}: {error}", archive_root.display()))?; 1431 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { 1432 return Err("archive root must be a real directory, not a symlink".to_owned()); 1433 } 1434 1435 for archive in &history.archive { 1436 let artifact = archive_root.join(&archive.artifact); 1437 let metadata = fs::symlink_metadata(&artifact) 1438 .map_err(|error| format!("inspect archive {}: {error}", artifact.display()))?; 1439 if metadata.file_type().is_symlink() || !metadata.is_file() { 1440 return Err(format!( 1441 "archive {} must be a regular non-symlink file", 1442 artifact.display() 1443 )); 1444 } 1445 if metadata.len() != archive.bytes { 1446 return Err(format!( 1447 "archive {} size drifted: expected {}, got {}", 1448 artifact.display(), 1449 archive.bytes, 1450 metadata.len() 1451 )); 1452 } 1453 let digest = sha256_file(&artifact)?; 1454 if digest != archive.sha256 { 1455 return Err(format!("archive {} digest drifted", artifact.display())); 1456 } 1457 if archive.kind == "git_bundle" { 1458 let output = Command::new("git") 1459 .args(["bundle", "verify"]) 1460 .arg(&artifact) 1461 .output() 1462 .map_err(|error| format!("run git bundle verify: {error}"))?; 1463 if !output.status.success() { 1464 return Err(format!( 1465 "git bundle verify failed for {}: {}", 1466 artifact.display(), 1467 String::from_utf8_lossy(&output.stderr).trim() 1468 )); 1469 } 1470 } else { 1471 validate_fast_export(&artifact, archive)?; 1472 } 1473 } 1474 Ok(()) 1475 } 1476 1477 fn validate_fast_export(path: &Path, archive: &Archive) -> Result<(), String> { 1478 let raw = fs::read_to_string(path) 1479 .map_err(|error| format!("read fast-export archive {}: {error}", path.display()))?; 1480 if !raw.contains(&format!("original-oid {}", archive.frozen_commit)) 1481 || !raw.contains("reset refs/heads/master") 1482 || !raw.contains("author triesap <tyson@radroots.org>") 1483 || !raw.contains("committer triesap <tyson@radroots.org>") 1484 || raw.to_ascii_lowercase().contains("github-actions") 1485 || raw.to_ascii_lowercase().contains("[bot]") 1486 { 1487 return Err("legacy Studio fast-export identity or ref drifted".to_owned()); 1488 } 1489 for line in raw.lines() { 1490 let Some(path) = line 1491 .strip_prefix("M ") 1492 .and_then(|line| line.splitn(3, ' ').nth(2)) 1493 else { 1494 continue; 1495 }; 1496 if !path.starts_with("studio_app/studio_app_core/") { 1497 return Err(format!("fast-export contains out-of-scope path {path}")); 1498 } 1499 } 1500 Ok(()) 1501 } 1502 1503 fn run_history_rehearsal() -> Result<(), String> { 1504 let fixture = tempfile::TempDir::new() 1505 .map_err(|error| format!("create history rehearsal root: {error}"))?; 1506 let source = fixture.path().join("source"); 1507 let restored = fixture.path().join("restored"); 1508 let filtered = fixture.path().join("filtered"); 1509 let import_target = fixture.path().join("import-target"); 1510 let bundle = fixture.path().join("source.bundle"); 1511 1512 create_history_fixture(&source)?; 1513 git(&source, &["bundle", "create", path_arg(&bundle)?, "master"])?; 1514 git( 1515 fixture.path(), 1516 &["clone", path_arg(&bundle)?, path_arg(&restored)?], 1517 )?; 1518 git(&restored, &["fsck", "--full", "--strict"])?; 1519 1520 git( 1521 fixture.path(), 1522 &["clone", path_arg(&bundle)?, path_arg(&filtered)?], 1523 )?; 1524 git( 1525 &filtered, 1526 &[ 1527 "filter-repo", 1528 "--force", 1529 "--path", 1530 "src/", 1531 "--path-rename", 1532 "src/:crates/imported/", 1533 ], 1534 )?; 1535 let commit_map_path = filtered.join(".git/filter-repo/commit-map"); 1536 let commit_map = parse_commit_map(&commit_map_path)?; 1537 verify_filtered_history(&source, &filtered, &commit_map, "src", "crates/imported")?; 1538 1539 create_import_target(&import_target)?; 1540 git( 1541 &import_target, 1542 &[ 1543 "fetch", 1544 path_arg(&filtered)?, 1545 "master:refs/remotes/rehearsal/imported", 1546 ], 1547 )?; 1548 let merge_tree = git_stdout( 1549 &import_target, 1550 &[ 1551 "merge-tree", 1552 "--write-tree", 1553 "--allow-unrelated-histories", 1554 "HEAD", 1555 "refs/remotes/rehearsal/imported", 1556 ], 1557 )?; 1558 let merge_tree = merge_tree.trim(); 1559 validate_oid(merge_tree, "rehearsal merge tree")?; 1560 if git_stdout(&import_target, &["cat-file", "-t", merge_tree])?.trim() != "tree" { 1561 return Err("no-op import rehearsal did not produce a tree".to_owned()); 1562 } 1563 if git_stdout(&import_target, &["status", "--porcelain"]) 1564 .map(|output| !output.trim().is_empty())? 1565 { 1566 return Err("no-op import rehearsal changed the target worktree".to_owned()); 1567 } 1568 1569 exercise_history_negative_cases(&source, &filtered, &commit_map)?; 1570 Ok(()) 1571 } 1572 1573 fn create_history_fixture(root: &Path) -> Result<(), String> { 1574 fs::create_dir(root).map_err(|error| format!("create {}: {error}", root.display()))?; 1575 git(root, &["init", "--initial-branch=master"])?; 1576 git(root, &["config", "user.name", "Radroots History Fixture"])?; 1577 git( 1578 root, 1579 &["config", "user.email", "history-fixture@radroots.org"], 1580 )?; 1581 write_fixture(root, "src/LICENSE", "MIT OR Apache-2.0\n")?; 1582 write_fixture(root, "src/item.txt", "base\n")?; 1583 write_fixture(root, "AGENTS.md", "context only\n")?; 1584 fixture_commit(root, "seed reusable source", "2001-01-01T00:00:00+00:00")?; 1585 1586 git(root, &["branch", "feature"])?; 1587 append_fixture(root, "src/item.txt", "main\n")?; 1588 fixture_commit(root, "extend main source", "2001-01-02T00:00:00+00:00")?; 1589 append_fixture(root, "AGENTS.md", "must not import\n")?; 1590 fixture_commit(root, "change donor context", "2001-01-03T00:00:00+00:00")?; 1591 1592 git(root, &["checkout", "feature"])?; 1593 write_fixture(root, "src/feature.txt", "feature\n")?; 1594 fixture_commit(root, "add feature source", "2001-01-04T00:00:00+00:00")?; 1595 git(root, &["checkout", "master"])?; 1596 git_with_identity( 1597 root, 1598 &["merge", "--no-ff", "feature", "-m", "merge reusable source"], 1599 "2001-01-05T00:00:00+00:00", 1600 )?; 1601 write_fixture(root, ".github/workflows/forbidden.yml", "forbidden: true\n")?; 1602 fixture_commit( 1603 root, 1604 "add forbidden donor automation", 1605 "2001-01-06T00:00:00+00:00", 1606 )?; 1607 append_fixture(root, "src/item.txt", "final\n")?; 1608 fixture_commit(root, "finish reusable source", "2001-01-07T00:00:00+00:00")?; 1609 Ok(()) 1610 } 1611 1612 fn create_import_target(root: &Path) -> Result<(), String> { 1613 fs::create_dir(root).map_err(|error| format!("create {}: {error}", root.display()))?; 1614 git(root, &["init", "--initial-branch=master"])?; 1615 git(root, &["config", "user.name", "Radroots History Fixture"])?; 1616 git( 1617 root, 1618 &["config", "user.email", "history-fixture@radroots.org"], 1619 )?; 1620 write_fixture(root, "README", "import target\n")?; 1621 fixture_commit(root, "seed import target", "2001-01-08T00:00:00+00:00") 1622 } 1623 1624 fn write_fixture(root: &Path, relative: &str, contents: &str) -> Result<(), String> { 1625 let path = root.join(relative); 1626 if let Some(parent) = path.parent() { 1627 fs::create_dir_all(parent) 1628 .map_err(|error| format!("create {}: {error}", parent.display()))?; 1629 } 1630 fs::write(&path, contents).map_err(|error| format!("write {}: {error}", path.display())) 1631 } 1632 1633 fn append_fixture(root: &Path, relative: &str, contents: &str) -> Result<(), String> { 1634 use std::io::Write; 1635 1636 let path = root.join(relative); 1637 let mut file = fs::OpenOptions::new() 1638 .append(true) 1639 .open(&path) 1640 .map_err(|error| format!("open {}: {error}", path.display()))?; 1641 file.write_all(contents.as_bytes()) 1642 .map_err(|error| format!("append {}: {error}", path.display())) 1643 } 1644 1645 fn fixture_commit(root: &Path, subject: &str, timestamp: &str) -> Result<(), String> { 1646 git(root, &["add", "--all"])?; 1647 git_with_identity(root, &["commit", "-m", subject], timestamp) 1648 } 1649 1650 fn git_with_identity(root: &Path, args: &[&str], timestamp: &str) -> Result<(), String> { 1651 let output = Command::new("git") 1652 .args(args) 1653 .current_dir(root) 1654 .env("GIT_AUTHOR_DATE", timestamp) 1655 .env("GIT_COMMITTER_DATE", timestamp) 1656 .output() 1657 .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; 1658 command_success(output, root, args).map(|_| ()) 1659 } 1660 1661 fn parse_commit_map(path: &Path) -> Result<Vec<CommitMapEntry>, String> { 1662 let raw = fs::read_to_string(path) 1663 .map_err(|error| format!("read commit map {}: {error}", path.display()))?; 1664 let mut entries = Vec::new(); 1665 for (line_index, line) in raw.lines().enumerate() { 1666 if line_index == 0 && line == "old new" { 1667 continue; 1668 } 1669 let fields = line.split_ascii_whitespace().collect::<Vec<_>>(); 1670 if fields.len() != 2 { 1671 return Err(format!("commit map line {} is malformed", line_index + 1)); 1672 } 1673 validate_oid(fields[0], "source commit-map object")?; 1674 validate_oid(fields[1], "target commit-map object")?; 1675 entries.push(CommitMapEntry { 1676 source: fields[0].to_owned(), 1677 target: (fields[1] != "0000000000000000000000000000000000000000") 1678 .then(|| fields[1].to_owned()), 1679 }); 1680 } 1681 if entries.is_empty() { 1682 return Err("commit map contains no entries".to_owned()); 1683 } 1684 let unique = entries 1685 .iter() 1686 .map(|entry| entry.source.as_str()) 1687 .collect::<BTreeSet<_>>(); 1688 if unique.len() != entries.len() { 1689 return Err("commit map contains duplicate source commits".to_owned()); 1690 } 1691 Ok(entries) 1692 } 1693 1694 fn verify_filtered_history( 1695 source: &Path, 1696 target: &Path, 1697 entries: &[CommitMapEntry], 1698 source_prefix: &str, 1699 target_prefix: &str, 1700 ) -> Result<(), String> { 1701 validate_relative_path(source_prefix)?; 1702 validate_relative_path(target_prefix)?; 1703 git(source, &["fsck", "--full", "--strict"])?; 1704 git(target, &["fsck", "--full", "--strict"])?; 1705 1706 let by_source = entries 1707 .iter() 1708 .map(|entry| (entry.source.as_str(), entry.target.as_deref())) 1709 .collect::<BTreeMap<_, _>>(); 1710 let mut saw_merge = false; 1711 let mut saw_omitted = false; 1712 for entry in entries { 1713 git( 1714 source, 1715 &["cat-file", "-e", &format!("{}^{{commit}}", entry.source)], 1716 )?; 1717 let Some(target_commit) = entry.target.as_deref() else { 1718 saw_omitted = true; 1719 continue; 1720 }; 1721 git( 1722 target, 1723 &["cat-file", "-e", &format!("{target_commit}^{{commit}}")], 1724 )?; 1725 verify_commit_metadata(source, target, &entry.source, target_commit)?; 1726 let source_parents = commit_parents(source, &entry.source)?; 1727 saw_merge |= source_parents.len() > 1; 1728 let mut expected_target_parents = Vec::new(); 1729 for parent in source_parents { 1730 collect_effective_parents(source, &parent, &by_source, &mut expected_target_parents)?; 1731 } 1732 deduplicate(&mut expected_target_parents); 1733 if commit_parents(target, target_commit)? != expected_target_parents { 1734 return Err(format!( 1735 "mapped parent closure drifted for {}", 1736 entry.source 1737 )); 1738 } 1739 let source_patch = normalized_patch(source, &entry.source, source_prefix, "__IMPORT__")?; 1740 let target_patch = normalized_patch(target, target_commit, target_prefix, "__IMPORT__")?; 1741 if source_patch != target_patch { 1742 return Err(format!("normalized patch drifted for {}", entry.source)); 1743 } 1744 } 1745 if !saw_merge || !saw_omitted { 1746 return Err("history rehearsal must include a merge and an omitted commit".to_owned()); 1747 } 1748 1749 let source_head = git_stdout(source, &["rev-parse", "master"])?; 1750 let source_head = source_head.trim(); 1751 let expected_target_head = by_source 1752 .get(source_head) 1753 .and_then(|target| *target) 1754 .ok_or_else(|| "source master is not retained in commit map".to_owned())?; 1755 if git_stdout(target, &["rev-parse", "master"])?.trim() != expected_target_head { 1756 return Err("filtered master has unexpected commits".to_owned()); 1757 } 1758 verify_final_tree( 1759 source, 1760 target, 1761 source_head, 1762 expected_target_head, 1763 source_prefix, 1764 target_prefix, 1765 )?; 1766 verify_context_firewall(target, expected_target_head, target_prefix)?; 1767 verify_commit_identities(target, expected_target_head)?; 1768 verify_follow_history(source, target, source_prefix, target_prefix)?; 1769 Ok(()) 1770 } 1771 1772 fn verify_commit_metadata( 1773 source: &Path, 1774 target: &Path, 1775 source_commit: &str, 1776 target_commit: &str, 1777 ) -> Result<(), String> { 1778 let format = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%s"; 1779 let source_meta = git_stdout( 1780 source, 1781 &["show", "-s", &format!("--format={format}"), source_commit], 1782 )?; 1783 let target_meta = git_stdout( 1784 target, 1785 &["show", "-s", &format!("--format={format}"), target_commit], 1786 )?; 1787 let mut source_fields = source_meta.trim_end().split('\0').collect::<Vec<_>>(); 1788 let mut target_fields = target_meta.trim_end().split('\0').collect::<Vec<_>>(); 1789 if source_fields.len() != 7 || target_fields.len() != 7 { 1790 return Err("commit metadata has unexpected cardinality".to_owned()); 1791 } 1792 let source_subject = source_fields.pop().expect("cardinality checked"); 1793 let target_subject = target_fields.pop().expect("cardinality checked"); 1794 if source_fields != target_fields || !message_is_preserved(source_subject, target_subject) { 1795 return Err(format!( 1796 "attribution or message drifted for {source_commit}" 1797 )); 1798 } 1799 Ok(()) 1800 } 1801 1802 fn message_is_preserved(source: &str, target: &str) -> bool { 1803 if source == target { 1804 return true; 1805 } 1806 let Some(scope) = target 1807 .strip_prefix(source) 1808 .and_then(|suffix| suffix.strip_prefix(" (")) 1809 .and_then(|suffix| suffix.strip_suffix(')')) 1810 else { 1811 return false; 1812 }; 1813 !scope.is_empty() 1814 && scope.bytes().all(|byte| { 1815 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'/') 1816 }) 1817 } 1818 1819 fn commit_parents(repo: &Path, commit: &str) -> Result<Vec<String>, String> { 1820 let output = git_stdout(repo, &["show", "-s", "--format=%P", commit])?; 1821 Ok(output.split_ascii_whitespace().map(str::to_owned).collect()) 1822 } 1823 1824 fn collect_effective_parents( 1825 source: &Path, 1826 commit: &str, 1827 by_source: &BTreeMap<&str, Option<&str>>, 1828 output: &mut Vec<String>, 1829 ) -> Result<(), String> { 1830 match by_source.get(commit).copied().flatten() { 1831 Some(target) => output.push(target.to_owned()), 1832 None => { 1833 for parent in commit_parents(source, commit)? { 1834 collect_effective_parents(source, &parent, by_source, output)?; 1835 } 1836 } 1837 } 1838 Ok(()) 1839 } 1840 1841 fn deduplicate(values: &mut Vec<String>) { 1842 let mut seen = BTreeSet::new(); 1843 values.retain(|value| seen.insert(value.clone())); 1844 } 1845 1846 fn normalized_patch( 1847 repo: &Path, 1848 commit: &str, 1849 prefix: &str, 1850 normalized_prefix: &str, 1851 ) -> Result<String, String> { 1852 let patch = git_stdout( 1853 repo, 1854 &[ 1855 "-c", 1856 "core.quotePath=false", 1857 "diff-tree", 1858 "--root", 1859 "-m", 1860 "-r", 1861 "--binary", 1862 "--full-index", 1863 "--no-commit-id", 1864 commit, 1865 "--", 1866 prefix, 1867 ], 1868 )?; 1869 Ok(patch.replace(prefix, normalized_prefix)) 1870 } 1871 1872 fn verify_final_tree( 1873 source: &Path, 1874 target: &Path, 1875 source_commit: &str, 1876 target_commit: &str, 1877 source_prefix: &str, 1878 target_prefix: &str, 1879 ) -> Result<(), String> { 1880 let source_tree = tree_manifest(source, source_commit, source_prefix, source_prefix)?; 1881 let target_tree = tree_manifest(target, target_commit, target_prefix, source_prefix)?; 1882 if source_tree != target_tree { 1883 return Err("filtered final tree drifted".to_owned()); 1884 } 1885 Ok(()) 1886 } 1887 1888 fn tree_manifest( 1889 repo: &Path, 1890 commit: &str, 1891 prefix: &str, 1892 normalized_prefix: &str, 1893 ) -> Result<String, String> { 1894 let output = git_stdout( 1895 repo, 1896 &["ls-tree", "-r", "--full-tree", commit, "--", prefix], 1897 )?; 1898 Ok(output.replace(prefix, normalized_prefix)) 1899 } 1900 1901 fn verify_context_firewall(repo: &Path, commit: &str, target_prefix: &str) -> Result<(), String> { 1902 let paths = git_stdout(repo, &["ls-tree", "-r", "--name-only", commit])?; 1903 let required_prefix = format!("{target_prefix}/"); 1904 for path in paths.lines() { 1905 let lower = path.to_ascii_lowercase(); 1906 if !path.starts_with(&required_prefix) 1907 || path.split('/').any(|segment| segment == ".github") 1908 || matches!(path.rsplit('/').next(), Some("AGENTS.md" | "CLAUDE.md")) 1909 || lower.ends_with(".pem") 1910 || lower.ends_with(".key") 1911 || lower.ends_with("/.env") 1912 { 1913 return Err(format!("context firewall rejected {path}")); 1914 } 1915 let contents = git_stdout(repo, &["show", &format!("{commit}:{path}")])?; 1916 let lower_contents = contents.to_ascii_lowercase(); 1917 if contents.contains("PRIVATE KEY-----") 1918 || contents.contains("ghp_") 1919 || lower_contents.contains("github-actions[bot]") 1920 { 1921 return Err(format!("secret or bot content rejected in {path}")); 1922 } 1923 } 1924 Ok(()) 1925 } 1926 1927 fn verify_commit_identities(repo: &Path, commit: &str) -> Result<(), String> { 1928 let identities = git_stdout(repo, &["log", "--format=%an%x00%ae%x00%cn%x00%ce", commit])?; 1929 let lower = identities.to_ascii_lowercase(); 1930 if lower.contains("github-actions") || lower.contains("[bot]") { 1931 return Err("bot identity found in filtered history".to_owned()); 1932 } 1933 Ok(()) 1934 } 1935 1936 fn verify_follow_history( 1937 source: &Path, 1938 target: &Path, 1939 source_prefix: &str, 1940 target_prefix: &str, 1941 ) -> Result<(), String> { 1942 let source_file = format!("{source_prefix}/item.txt"); 1943 let target_file = format!("{target_prefix}/item.txt"); 1944 let source_log = git_stdout( 1945 source, 1946 &["log", "--follow", "--format=%s", "--", &source_file], 1947 )?; 1948 let target_log = git_stdout( 1949 target, 1950 &["log", "--follow", "--format=%s", "--", &target_file], 1951 )?; 1952 if source_log != target_log { 1953 return Err("git log --follow attribution drifted".to_owned()); 1954 } 1955 Ok(()) 1956 } 1957 1958 fn exercise_history_negative_cases( 1959 source: &Path, 1960 filtered: &Path, 1961 entries: &[CommitMapEntry], 1962 ) -> Result<(), String> { 1963 if message_is_preserved("preserve this", "rewritten message") 1964 || message_is_preserved("preserve this", "preserve this (Bad Scope)") 1965 { 1966 return Err("message negative fixture was accepted".to_owned()); 1967 } 1968 let malformed_map = filtered.join("malformed-commit-map"); 1969 fs::write(&malformed_map, "old new\nnot-an-oid still-not-an-oid\n") 1970 .map_err(|error| format!("write negative commit map: {error}"))?; 1971 if parse_commit_map(&malformed_map).is_ok() { 1972 return Err("malformed commit map was accepted".to_owned()); 1973 } 1974 let head = entries 1975 .iter() 1976 .rev() 1977 .find_map(|entry| entry.target.as_deref()) 1978 .ok_or_else(|| "negative fixture has no target head".to_owned())?; 1979 if verify_context_firewall(filtered, head, "wrong/prefix").is_ok() { 1980 return Err("context-firewall negative fixture was accepted".to_owned()); 1981 } 1982 if normalized_patch(source, &entries[0].source, "src", "__IMPORT__")? 1983 == normalized_patch(filtered, head, "crates/imported", "__WRONG__")? 1984 { 1985 return Err("normalized-patch negative fixture was accepted".to_owned()); 1986 } 1987 1988 let source_head = git_stdout(source, &["rev-parse", "master"])?; 1989 let source_head = source_head.trim(); 1990 let negative_root = filtered 1991 .parent() 1992 .ok_or_else(|| "filtered fixture has no parent".to_owned())?; 1993 1994 let context = clone_negative(filtered, negative_root, "negative-context")?; 1995 write_fixture( 1996 &context, 1997 "AGENTS.md", 1998 "must not cross the source boundary\n", 1999 )?; 2000 fixture_commit(&context, "inject context", "2001-02-01T00:00:00+00:00")?; 2001 let context_head = git_stdout(&context, &["rev-parse", "HEAD"])?; 2002 if verify_context_firewall(&context, context_head.trim(), "crates/imported").is_ok() { 2003 return Err("context negative fixture was accepted".to_owned()); 2004 } 2005 2006 let workflow = clone_negative(filtered, negative_root, "negative-workflow")?; 2007 write_fixture( 2008 &workflow, 2009 ".github/workflows/forbidden.yml", 2010 "forbidden: true\n", 2011 )?; 2012 fixture_commit(&workflow, "inject workflow", "2001-02-02T00:00:00+00:00")?; 2013 let workflow_head = git_stdout(&workflow, &["rev-parse", "HEAD"])?; 2014 if verify_context_firewall(&workflow, workflow_head.trim(), "crates/imported").is_ok() { 2015 return Err("GitHub-workflow negative fixture was accepted".to_owned()); 2016 } 2017 2018 let secret = clone_negative(filtered, negative_root, "negative-secret")?; 2019 write_fixture( 2020 &secret, 2021 "crates/imported/secret.pem", 2022 "-----BEGIN PRIVATE KEY-----\nfixture\n", 2023 )?; 2024 fixture_commit(&secret, "inject secret", "2001-02-03T00:00:00+00:00")?; 2025 let secret_head = git_stdout(&secret, &["rev-parse", "HEAD"])?; 2026 if verify_context_firewall(&secret, secret_head.trim(), "crates/imported").is_ok() { 2027 return Err("secret negative fixture was accepted".to_owned()); 2028 } 2029 2030 let bot = clone_negative(filtered, negative_root, "negative-bot")?; 2031 git_commit_with_actor( 2032 &bot, 2033 "inject bot identity", 2034 "github-actions[bot]", 2035 "41898282+github-actions[bot]@users.noreply.github.com", 2036 "2001-02-04T00:00:00+00:00", 2037 true, 2038 )?; 2039 let bot_head = git_stdout(&bot, &["rev-parse", "HEAD"])?; 2040 if verify_commit_identities(&bot, bot_head.trim()).is_ok() { 2041 return Err("bot-identity negative fixture was accepted".to_owned()); 2042 } 2043 2044 let license = clone_negative(filtered, negative_root, "negative-license")?; 2045 write_fixture(&license, "crates/imported/LICENSE", "GPL-3.0-only\n")?; 2046 fixture_commit(&license, "change license", "2001-02-05T00:00:00+00:00")?; 2047 let license_head = git_stdout(&license, &["rev-parse", "HEAD"])?; 2048 if verify_final_tree( 2049 source, 2050 &license, 2051 source_head, 2052 license_head.trim(), 2053 "src", 2054 "crates/imported", 2055 ) 2056 .is_ok() 2057 { 2058 return Err("license/tree negative fixture was accepted".to_owned()); 2059 } 2060 2061 let attribution = clone_negative(filtered, negative_root, "negative-attribution")?; 2062 git_commit_with_actor( 2063 &attribution, 2064 "finish reusable source", 2065 "Wrong Author", 2066 "wrong-author@radroots.org", 2067 "2001-01-07T00:00:00+00:00", 2068 false, 2069 )?; 2070 let attribution_head = git_stdout(&attribution, &["rev-parse", "HEAD"])?; 2071 if verify_commit_metadata(source, &attribution, source_head, attribution_head.trim()).is_ok() { 2072 return Err("attribution negative fixture was accepted".to_owned()); 2073 } 2074 2075 let timestamp = clone_negative(filtered, negative_root, "negative-timestamp")?; 2076 git_commit_with_actor( 2077 ×tamp, 2078 "finish reusable source", 2079 "Radroots History Fixture", 2080 "history-fixture@radroots.org", 2081 "2002-01-07T00:00:00+00:00", 2082 false, 2083 )?; 2084 let timestamp_head = git_stdout(×tamp, &["rev-parse", "HEAD"])?; 2085 if verify_commit_metadata(source, ×tamp, source_head, timestamp_head.trim()).is_ok() { 2086 return Err("timestamp negative fixture was accepted".to_owned()); 2087 } 2088 2089 let message = clone_negative(filtered, negative_root, "negative-message")?; 2090 git_commit_with_actor( 2091 &message, 2092 "replace the original message", 2093 "Radroots History Fixture", 2094 "history-fixture@radroots.org", 2095 "2001-01-07T00:00:00+00:00", 2096 false, 2097 )?; 2098 let message_head = git_stdout(&message, &["rev-parse", "HEAD"])?; 2099 if verify_commit_metadata(source, &message, source_head, message_head.trim()).is_ok() { 2100 return Err("message negative fixture was accepted".to_owned()); 2101 } 2102 2103 let follow = clone_negative(filtered, negative_root, "negative-follow")?; 2104 append_fixture(&follow, "crates/imported/item.txt", "unmapped\n")?; 2105 fixture_commit( 2106 &follow, 2107 "inject unmapped history", 2108 "2001-02-06T00:00:00+00:00", 2109 )?; 2110 if verify_follow_history(source, &follow, "src", "crates/imported").is_ok() { 2111 return Err("git-log-follow negative fixture was accepted".to_owned()); 2112 } 2113 2114 let mut broken_map = entries.to_vec(); 2115 let replacement = broken_map 2116 .iter() 2117 .find_map(|entry| entry.target.clone()) 2118 .ok_or_else(|| "negative fixture has no replacement target".to_owned())?; 2119 broken_map 2120 .last_mut() 2121 .ok_or_else(|| "negative fixture has no final map entry".to_owned())? 2122 .target = Some(replacement); 2123 if verify_filtered_history(source, filtered, &broken_map, "src", "crates/imported").is_ok() { 2124 return Err("commit-map topology negative fixture was accepted".to_owned()); 2125 } 2126 2127 let corrupt = clone_negative(filtered, negative_root, "negative-fsck")?; 2128 let payload = corrupt.join("fsck-negative-payload"); 2129 fs::write(&payload, "unique fsck negative fixture payload\n") 2130 .map_err(|error| format!("write fsck negative payload: {error}"))?; 2131 let object = git_stdout(&corrupt, &["hash-object", "-w", path_arg(&payload)?])?; 2132 let object = object.trim(); 2133 validate_oid(object, "fsck negative object")?; 2134 let object_path = corrupt 2135 .join(".git/objects") 2136 .join(&object[..2]) 2137 .join(&object[2..]); 2138 fs::remove_file(&object_path).map_err(|error| { 2139 format!( 2140 "unlink exact temporary negative object {}: {error}", 2141 object_path.display() 2142 ) 2143 })?; 2144 fs::write(&object_path, "corrupt") 2145 .map_err(|error| format!("corrupt negative object {}: {error}", object_path.display()))?; 2146 if git(&corrupt, &["fsck", "--full", "--strict"]).is_ok() { 2147 return Err("fsck negative fixture was accepted".to_owned()); 2148 } 2149 Ok(()) 2150 } 2151 2152 fn clone_negative(source: &Path, root: &Path, name: &str) -> Result<std::path::PathBuf, String> { 2153 let target = root.join(name); 2154 git(root, &["clone", path_arg(source)?, path_arg(&target)?])?; 2155 git( 2156 &target, 2157 &["config", "user.name", "Radroots History Fixture"], 2158 )?; 2159 git( 2160 &target, 2161 &["config", "user.email", "history-fixture@radroots.org"], 2162 )?; 2163 Ok(target) 2164 } 2165 2166 fn git_commit_with_actor( 2167 root: &Path, 2168 subject: &str, 2169 actor: &str, 2170 email: &str, 2171 timestamp: &str, 2172 allow_empty: bool, 2173 ) -> Result<(), String> { 2174 let mut command = Command::new("git"); 2175 command 2176 .current_dir(root) 2177 .args(["commit", "--amend", "-m", subject]) 2178 .env("GIT_AUTHOR_NAME", actor) 2179 .env("GIT_AUTHOR_EMAIL", email) 2180 .env("GIT_COMMITTER_NAME", actor) 2181 .env("GIT_COMMITTER_EMAIL", email) 2182 .env("GIT_AUTHOR_DATE", timestamp) 2183 .env("GIT_COMMITTER_DATE", timestamp); 2184 if allow_empty { 2185 command.arg("--allow-empty"); 2186 } 2187 let output = command 2188 .output() 2189 .map_err(|error| format!("run negative commit fixture: {error}"))?; 2190 command_success(output, root, &["commit", "--amend"]).map(|_| ()) 2191 } 2192 2193 fn git(root: &Path, args: &[&str]) -> Result<(), String> { 2194 let output = Command::new("git") 2195 .args(args) 2196 .current_dir(root) 2197 .output() 2198 .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; 2199 command_success(output, root, args).map(|_| ()) 2200 } 2201 2202 fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> { 2203 String::from_utf8(git_bytes(root, args)?) 2204 .map_err(|error| format!("git {} emitted non-UTF-8 output: {error}", args.join(" "))) 2205 } 2206 2207 fn git_bytes(root: &Path, args: &[&str]) -> Result<Vec<u8>, String> { 2208 let output = Command::new("git") 2209 .args(args) 2210 .current_dir(root) 2211 .output() 2212 .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; 2213 command_success(output, root, args) 2214 } 2215 2216 fn command_success( 2217 output: std::process::Output, 2218 root: &Path, 2219 args: &[&str], 2220 ) -> Result<Vec<u8>, String> { 2221 if output.status.success() { 2222 return Ok(output.stdout); 2223 } 2224 Err(format!( 2225 "git {} failed in {}: {}", 2226 args.join(" "), 2227 root.display(), 2228 String::from_utf8_lossy(&output.stderr).trim() 2229 )) 2230 } 2231 2232 fn path_arg(path: &Path) -> Result<&str, String> { 2233 path.to_str() 2234 .ok_or_else(|| format!("path {} is not valid UTF-8", path.display())) 2235 } 2236 2237 fn sha256_file(path: &Path) -> Result<String, String> { 2238 let mut file = fs::File::open(path) 2239 .map_err(|error| format!("open archive {}: {error}", path.display()))?; 2240 let mut hasher = Sha256::new(); 2241 let mut buffer = [0_u8; 64 * 1024]; 2242 loop { 2243 let read = file 2244 .read(&mut buffer) 2245 .map_err(|error| format!("read archive {}: {error}", path.display()))?; 2246 if read == 0 { 2247 break; 2248 } 2249 hasher.update(&buffer[..read]); 2250 } 2251 Ok(format!("{:x}", hasher.finalize())) 2252 } 2253 2254 fn to_unique_set<'a>(values: &'a [String], context: &str) -> Result<BTreeSet<&'a str>, String> { 2255 let set = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); 2256 if set.len() != values.len() || set.iter().any(|value| value.trim().is_empty()) { 2257 return Err(format!("{context} entries must be nonempty and unique")); 2258 } 2259 Ok(set) 2260 } 2261 2262 fn validate_sha256(value: &str, context: &str) -> Result<(), String> { 2263 if value.len() != 64 2264 || !value 2265 .bytes() 2266 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 2267 { 2268 return Err(format!("{context} must be lowercase 64-hex SHA-256")); 2269 } 2270 Ok(()) 2271 } 2272 2273 fn validate_artifact_name(value: &str) -> Result<(), String> { 2274 if value.is_empty() 2275 || value.contains('/') 2276 || value.contains('\\') 2277 || value == "." 2278 || value == ".." 2279 || !value 2280 .bytes() 2281 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) 2282 { 2283 return Err("archive artifact must be a safe portable file name".to_owned()); 2284 } 2285 Ok(()) 2286 } 2287 2288 fn validate_oid(value: &str, context: &str) -> Result<(), String> { 2289 if value.len() != 40 2290 || !value 2291 .bytes() 2292 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 2293 { 2294 return Err(format!( 2295 "{context} must be a full lowercase 40-hex Git object id" 2296 )); 2297 } 2298 Ok(()) 2299 } 2300 2301 fn validate_identifier(value: &str, context: &str) -> Result<(), String> { 2302 if value.is_empty() 2303 || !value 2304 .bytes() 2305 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') 2306 { 2307 return Err(format!("{context} must use lowercase snake case")); 2308 } 2309 Ok(()) 2310 } 2311 2312 fn validate_relative_path(value: &str) -> Result<(), String> { 2313 let path = Path::new(value); 2314 if path.as_os_str().is_empty() 2315 || path.is_absolute() 2316 || value.contains('\\') 2317 || value 2318 .split('/') 2319 .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) 2320 || path 2321 .components() 2322 .any(|component| !matches!(component, Component::Normal(_))) 2323 { 2324 return Err(format!( 2325 "surface path {value:?} must be a safe relative path" 2326 )); 2327 } 2328 Ok(()) 2329 } 2330 2331 fn validate_date(value: &str) -> Result<(), String> { 2332 let bytes = value.as_bytes(); 2333 if bytes.len() != 10 2334 || bytes[4] != b'-' 2335 || bytes[7] != b'-' 2336 || bytes 2337 .iter() 2338 .enumerate() 2339 .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) 2340 { 2341 return Err("captured_date must use YYYY-MM-DD".to_owned()); 2342 } 2343 Ok(()) 2344 } 2345 2346 #[cfg(test)] 2347 mod tests { 2348 use super::*; 2349 2350 #[test] 2351 fn checked_in_baseline_and_step_map_validate() { 2352 validate_baseline_contracts(&crate::workspace_root()) 2353 .expect("checked-in consolidation baseline"); 2354 validate_history_contract(&crate::workspace_root(), None) 2355 .expect("checked-in history contract"); 2356 } 2357 2358 #[test] 2359 fn object_ids_require_full_lowercase_hex() { 2360 assert!(validate_oid("0123456789abcdef0123456789abcdef01234567", "commit").is_ok()); 2361 assert!(validate_oid("0123456", "commit").is_err()); 2362 assert!(validate_oid("0123456789ABCDEF0123456789abcdef01234567", "commit").is_err()); 2363 assert!(validate_oid("g123456789abcdef0123456789abcdef01234567", "commit").is_err()); 2364 } 2365 2366 #[test] 2367 fn paths_reject_escape_and_non_normal_components() { 2368 assert!(validate_relative_path("crates/sdk").is_ok()); 2369 assert!(validate_relative_path("../sdk").is_err()); 2370 assert!(validate_relative_path("crates/./sdk").is_err()); 2371 assert!(validate_relative_path("/crates/sdk").is_err()); 2372 } 2373 2374 #[test] 2375 fn retired_import_targets_must_be_absent() { 2376 let root = tempfile::TempDir::new().expect("temporary workspace"); 2377 let target = "imports/retired_core"; 2378 let path_maps = vec![PathMap { 2379 source_id: "retired_core".to_owned(), 2380 source: "legacy/core".to_owned(), 2381 target: target.to_owned(), 2382 package: "retired_core".to_owned(), 2383 license: "MPL-2.0".to_owned(), 2384 disposition: "import_unique_behavior_then_retire".to_owned(), 2385 }]; 2386 2387 validate_retired_import_targets(root.path(), &path_maps).expect("absent retired import"); 2388 fs::create_dir_all(root.path().join(target)).expect("create retired import fixture"); 2389 assert!(validate_retired_import_targets(root.path(), &path_maps).is_err()); 2390 } 2391 2392 #[test] 2393 fn step_ranges_must_cover_every_step_once() { 2394 let valid = StepMap { 2395 schema_version: 1, 2396 map_id: STEP_MAP_ID.to_owned(), 2397 source_step_count: EXPECTED_HANDOFF_STEPS, 2398 source_sequence: "implementation/COMMIT_SEQUENCE.md".to_owned(), 2399 range: vec![StepRange { 2400 start: 1, 2401 end: EXPECTED_HANDOFF_STEPS, 2402 owners: vec!["rcld-rlc-010".to_owned()], 2403 disposition: "execute".to_owned(), 2404 reason: "fixture".to_owned(), 2405 }], 2406 }; 2407 validate_step_map(&valid).expect("complete map"); 2408 2409 let mut gapped = valid; 2410 gapped.range[0].start = 2; 2411 assert!(validate_step_map(&gapped).is_err()); 2412 } 2413 2414 #[test] 2415 fn archive_names_and_digests_are_strict() { 2416 assert!(validate_artifact_name("sdk-0123.bundle").is_ok()); 2417 assert!(validate_artifact_name("../sdk.bundle").is_err()); 2418 assert!(validate_artifact_name("sdk/bundle").is_err()); 2419 assert!(validate_sha256(&"a".repeat(64), "digest").is_ok()); 2420 assert!(validate_sha256(&"A".repeat(64), "digest").is_err()); 2421 } 2422 2423 #[test] 2424 fn merge_bearing_history_rehearsal_is_green() { 2425 run_history_rehearsal().expect("history rewrite rehearsal"); 2426 } 2427 }