build_control.rs (52276B)
1 use std::{ 2 collections::BTreeSet, 3 fs, 4 io::Write, 5 path::{Component, Path, PathBuf}, 6 process::Command, 7 }; 8 9 use fs2::FileExt; 10 use serde::{Deserialize, Serialize}; 11 use sha2::{Digest, Sha256}; 12 13 use crate::build_output::is_build_output_directory; 14 15 const SOURCE_LOCK_NAME: &str = "radroots.lib.source-lock.v1.toml"; 16 const CONSUMER_MARKER: &str = ".radroots-consumer-root"; 17 const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml"; 18 const REPOSITORY: &str = "https://github.com/radrootslabs/lib"; 19 const ARCHITECTURE: &str = "radroots.crates.release.v2"; 20 const VERSION: &str = "0.1.0-alpha"; 21 22 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 23 pub enum Operation { 24 Check, 25 Test, 26 Clippy, 27 } 28 29 impl Operation { 30 pub fn cargo_subcommand(self) -> &'static str { 31 match self { 32 Self::Check => "check", 33 Self::Test => "test", 34 Self::Clippy => "clippy", 35 } 36 } 37 } 38 39 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 40 pub enum Mode { 41 Check, 42 Write, 43 } 44 45 #[derive(Debug, Deserialize)] 46 #[serde(deny_unknown_fields)] 47 struct PackageGroups { 48 schema: String, 49 catalog_sha256: String, 50 group: Vec<PackageGroup>, 51 } 52 53 #[derive(Debug, Deserialize)] 54 #[serde(deny_unknown_fields)] 55 struct PackageGroup { 56 id: String, 57 packages: Vec<String>, 58 active_packages: Vec<String>, 59 reserved_packages: Vec<String>, 60 } 61 62 #[derive(Clone, Debug, Deserialize, Serialize)] 63 #[serde(deny_unknown_fields)] 64 pub struct SourceLock { 65 pub schema: String, 66 pub repository: String, 67 pub revision: String, 68 pub architecture: String, 69 pub workspace_catalog_sha256: String, 70 pub version: String, 71 pub source_archive_sha256: Option<String>, 72 #[serde(default = "default_consumer_lockfile")] 73 pub lockfile: String, 74 pub lockfile_sha256: String, 75 } 76 77 fn default_consumer_lockfile() -> String { 78 "Cargo.lock".to_owned() 79 } 80 81 #[derive(Clone, Debug)] 82 pub struct ConsumerRoot { 83 path: PathBuf, 84 product: String, 85 source_lock: SourceLock, 86 } 87 88 impl ConsumerRoot { 89 pub fn open(path: &Path) -> Result<Self, String> { 90 require_absolute_real_directory(path, "consumer root")?; 91 let canonical = fs::canonicalize(path) 92 .map_err(|error| format!("canonicalize consumer root {}: {error}", path.display()))?; 93 let marker = read_regular_no_follow(&canonical.join(CONSUMER_MARKER))?; 94 let product = String::from_utf8(marker) 95 .map_err(|error| format!("consumer marker is not UTF-8: {error}"))? 96 .trim() 97 .to_owned(); 98 if !matches!(product.as_str(), "sdk" | "myc" | "rhi") { 99 return Err("consumer marker must contain sdk, myc, or rhi".to_owned()); 100 } 101 let source_lock_path = canonical.join(SOURCE_LOCK_NAME); 102 let source_lock = parse_source_lock(&source_lock_path)?; 103 validate_source_lock(&source_lock)?; 104 validate_consumer_files(&canonical, &source_lock)?; 105 Ok(Self { 106 path: canonical, 107 product, 108 source_lock, 109 }) 110 } 111 112 fn output(&self, relative: &Path) -> Result<PathBuf, String> { 113 validate_relative_path(relative, "artifact output")?; 114 let output = self.path.join(relative); 115 ensure_no_symlink_components(&self.path, relative)?; 116 Ok(output) 117 } 118 } 119 120 #[derive(Debug, Deserialize, Serialize)] 121 #[serde(deny_unknown_fields)] 122 struct CacheManifest { 123 schema: String, 124 repository: String, 125 revision: String, 126 workspace_catalog_sha256: String, 127 source_archive_sha256: Option<String>, 128 tree: String, 129 } 130 131 #[derive(Debug, Deserialize)] 132 struct CargoLock { 133 #[serde(default)] 134 package: Vec<CargoLockPackage>, 135 } 136 137 #[derive(Debug, Deserialize)] 138 struct CargoLockPackage { 139 source: Option<String>, 140 } 141 142 #[derive(Debug, Serialize)] 143 struct ArtifactManifest<'a> { 144 schema: &'static str, 145 product: &'a str, 146 target: &'a str, 147 language: &'a str, 148 external_names: Vec<&'a str>, 149 files: Vec<ArtifactFile>, 150 provenance: Provenance<'a>, 151 } 152 153 #[derive(Debug, Serialize)] 154 struct ArtifactFile { 155 path: String, 156 bytes: u64, 157 sha256: String, 158 } 159 160 #[derive(Debug, Serialize)] 161 struct Provenance<'a> { 162 repository: &'a str, 163 revision: &'a str, 164 architecture: &'a str, 165 catalog_sha256: &'a str, 166 lockfile_sha256: &'a str, 167 source_archive_sha256: Option<&'a str>, 168 source_date_epoch: u64, 169 builder_id: &'a str, 170 features: Vec<&'a str>, 171 } 172 173 pub fn group_plan( 174 workspace_root: &Path, 175 group: &str, 176 operation: Operation, 177 include_reserved: bool, 178 ) -> Result<Vec<String>, String> { 179 validate_identifier(group, "group")?; 180 let projection_path = workspace_root.join("contracts/crates/generated/package_groups.v1.toml"); 181 let bytes = read_regular_no_follow(&projection_path)?; 182 let raw = std::str::from_utf8(&bytes) 183 .map_err(|error| format!("package group projection is not UTF-8: {error}"))?; 184 let projection = toml::from_str::<PackageGroups>(raw) 185 .map_err(|error| format!("parse package group projection: {error}"))?; 186 if projection.schema != "radroots.workspace.package-groups.v1" { 187 return Err("package group projection schema drifted".to_owned()); 188 } 189 validate_sha256(&projection.catalog_sha256, "catalog digest")?; 190 let catalog = read_regular_no_follow(&workspace_root.join(CATALOG_RELATIVE))?; 191 if sha256(&catalog) != projection.catalog_sha256 { 192 return Err("package group projection is stale".to_owned()); 193 } 194 let selected = projection 195 .group 196 .iter() 197 .find(|candidate| candidate.id == group) 198 .ok_or_else(|| format!("unknown catalog group {group}"))?; 199 let expected = selected 200 .active_packages 201 .iter() 202 .chain(selected.reserved_packages.iter()) 203 .cloned() 204 .collect::<BTreeSet<_>>(); 205 if selected.packages.iter().cloned().collect::<BTreeSet<_>>() != expected 206 || selected.active_packages.is_empty() 207 { 208 return Err(format!("catalog group {group} is malformed or not active")); 209 } 210 let packages = if include_reserved { 211 &selected.packages 212 } else { 213 &selected.active_packages 214 }; 215 let mut plan = vec![ 216 operation.cargo_subcommand().to_owned(), 217 "--locked".to_owned(), 218 ]; 219 if operation != Operation::Test { 220 plan.push("--all-targets".to_owned()); 221 } 222 for package in packages { 223 plan.push("-p".to_owned()); 224 plan.push(package.clone()); 225 } 226 if operation == Operation::Clippy { 227 plan.push("--".to_owned()); 228 plan.push("-D".to_owned()); 229 plan.push("warnings".to_owned()); 230 } 231 Ok(plan) 232 } 233 234 pub fn execute_group_plan(workspace_root: &Path, plan: &[String]) -> Result<(), String> { 235 let output = Command::new("cargo") 236 .args(plan) 237 .current_dir(workspace_root) 238 .status() 239 .map_err(|error| format!("run catalog group plan: {error}"))?; 240 if output.success() { 241 Ok(()) 242 } else { 243 Err(format!("catalog group plan failed with {output}")) 244 } 245 } 246 247 pub fn print_plan(plan: &[String]) { 248 println!("cargo {}", plan.join(" ")); 249 } 250 251 pub fn validate_consumer(path: &Path) -> Result<ConsumerRoot, String> { 252 ConsumerRoot::open(path) 253 } 254 255 pub fn materialize( 256 consumer_path: &Path, 257 cache_root: &Path, 258 offline: bool, 259 ) -> Result<PathBuf, String> { 260 let consumer = ConsumerRoot::open(consumer_path)?; 261 materialize_from( 262 &consumer, 263 cache_root, 264 offline, 265 &consumer.source_lock.repository, 266 ) 267 } 268 269 fn materialize_from( 270 consumer: &ConsumerRoot, 271 cache_root: &Path, 272 offline: bool, 273 fetch_url: &str, 274 ) -> Result<PathBuf, String> { 275 require_absolute_real_directory(cache_root, "cache root")?; 276 let key = format!( 277 "{}-{}", 278 consumer.source_lock.revision, consumer.source_lock.workspace_catalog_sha256 279 ); 280 let destination = cache_root.join(key); 281 let lock_path = cache_root.join(".radroots-source-cache.lock"); 282 let lock = fs::OpenOptions::new() 283 .create(true) 284 .truncate(false) 285 .read(true) 286 .write(true) 287 .open(&lock_path) 288 .map_err(|error| format!("open source cache lock: {error}"))?; 289 lock.lock_exclusive() 290 .map_err(|error| format!("lock source cache: {error}"))?; 291 let result = if destination.exists() { 292 verify_cache(&destination, &consumer.source_lock).map(|()| destination.clone()) 293 } else if offline { 294 Err("offline source materialization requires a verified cache entry".to_owned()) 295 } else { 296 prefetch_cache(cache_root, &destination, &consumer.source_lock, fetch_url)?; 297 verify_cache(&destination, &consumer.source_lock)?; 298 Ok(destination.clone()) 299 }; 300 FileExt::unlock(&lock).map_err(|error| format!("unlock source cache: {error}"))?; 301 result 302 } 303 304 fn prefetch_cache( 305 cache_root: &Path, 306 destination: &Path, 307 source_lock: &SourceLock, 308 fetch_url: &str, 309 ) -> Result<(), String> { 310 let staging = tempfile::Builder::new() 311 .prefix(".radroots-source-stage-") 312 .tempdir_in(cache_root) 313 .map_err(|error| format!("create source cache staging directory: {error}"))?; 314 git(staging.path(), &["init", "--quiet"])?; 315 git(staging.path(), &["remote", "add", "origin", fetch_url])?; 316 git( 317 staging.path(), 318 &[ 319 "fetch", 320 "--quiet", 321 "--depth=1", 322 "origin", 323 &source_lock.revision, 324 ], 325 )?; 326 git( 327 staging.path(), 328 &["checkout", "--quiet", "--detach", "FETCH_HEAD"], 329 )?; 330 let tree = git_stdout(staging.path(), &["rev-parse", "HEAD^{tree}"])?; 331 let manifest = CacheManifest { 332 schema: "radroots.source-cache.v1".to_owned(), 333 repository: source_lock.repository.clone(), 334 revision: source_lock.revision.clone(), 335 workspace_catalog_sha256: source_lock.workspace_catalog_sha256.clone(), 336 source_archive_sha256: source_lock.source_archive_sha256.clone(), 337 tree: tree.trim().to_owned(), 338 }; 339 let raw = toml::to_string(&manifest) 340 .map_err(|error| format!("serialize source cache manifest: {error}"))?; 341 atomic_write( 342 &staging.path().join(".radroots-source-cache.v1.toml"), 343 raw.as_bytes(), 344 )?; 345 let staging_path = staging.keep(); 346 fs::rename(&staging_path, destination).map_err(|error| { 347 format!( 348 "install source cache {} -> {}: {error}", 349 staging_path.display(), 350 destination.display() 351 ) 352 })?; 353 set_readonly_tree(destination) 354 } 355 356 fn verify_cache(path: &Path, source_lock: &SourceLock) -> Result<(), String> { 357 require_absolute_real_directory(path, "cache entry")?; 358 let head = git_stdout(path, &["rev-parse", "HEAD"])?; 359 let tree = git_stdout(path, &["rev-parse", "HEAD^{tree}"])?; 360 if head.trim() != source_lock.revision { 361 return Err("source cache revision drifted".to_owned()); 362 } 363 git(path, &["diff", "--quiet", "--no-ext-diff"])?; 364 git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?; 365 verify_untracked_cache_paths(path)?; 366 let manifest_path = path.join(".radroots-source-cache.v1.toml"); 367 let bytes = read_regular_no_follow(&manifest_path)?; 368 let raw = std::str::from_utf8(&bytes) 369 .map_err(|error| format!("source cache manifest is not UTF-8: {error}"))?; 370 let manifest = toml::from_str::<CacheManifest>(raw) 371 .map_err(|error| format!("parse source cache manifest: {error}"))?; 372 if manifest.schema != "radroots.source-cache.v1" 373 || manifest.repository != source_lock.repository 374 || manifest.revision != source_lock.revision 375 || manifest.workspace_catalog_sha256 != source_lock.workspace_catalog_sha256 376 || manifest.source_archive_sha256 != source_lock.source_archive_sha256 377 || manifest.tree != tree.trim() 378 { 379 return Err("source cache manifest drifted".to_owned()); 380 } 381 let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?; 382 if sha256(&catalog) != source_lock.workspace_catalog_sha256 { 383 return Err("source cache catalog digest drifted".to_owned()); 384 } 385 Ok(()) 386 } 387 388 pub fn verify_source_archive(path: &Path, expected_sha256: &str) -> Result<(), String> { 389 if !path.is_absolute() { 390 return Err("source archive path must be absolute".to_owned()); 391 } 392 validate_sha256(expected_sha256, "source archive digest")?; 393 let bytes = read_regular_no_follow(path)?; 394 if sha256(&bytes) != expected_sha256 { 395 return Err("source archive digest drifted".to_owned()); 396 } 397 verify_bundle(path) 398 } 399 400 pub fn create_source_archive( 401 source_root: &Path, 402 revision: &str, 403 output_path: &Path, 404 ) -> Result<String, String> { 405 require_absolute_real_directory(source_root, "archive source root")?; 406 validate_oid(revision, "archive revision")?; 407 if !output_path.is_absolute() { 408 return Err("source archive output must be absolute".to_owned()); 409 } 410 git( 411 source_root, 412 &["cat-file", "-e", &format!("{revision}^{{commit}}")], 413 )?; 414 let parent = output_path 415 .parent() 416 .ok_or_else(|| "source archive output has no parent".to_owned())?; 417 create_directories_no_follow(parent)?; 418 if let Ok(metadata) = fs::symlink_metadata(output_path) 419 && (metadata.file_type().is_symlink() || !metadata.is_file()) 420 { 421 return Err("source archive output must be a regular file".to_owned()); 422 } 423 let temporary = tempfile::Builder::new() 424 .prefix(".radroots-source-archive-") 425 .tempfile_in(parent) 426 .map_err(|error| format!("stage source archive: {error}"))?; 427 let temporary_path = temporary.path().to_path_buf(); 428 temporary 429 .close() 430 .map_err(|error| format!("prepare source archive staging path: {error}"))?; 431 let archive_repo = tempfile::TempDir::new_in(parent) 432 .map_err(|error| format!("create archive staging repository: {error}"))?; 433 git(archive_repo.path(), &["init", "--bare", "--quiet"])?; 434 let fetch = Command::new("git") 435 .args(["fetch", "--quiet", "--no-tags"]) 436 .arg(source_root) 437 .arg(format!("{revision}:refs/heads/archive")) 438 .current_dir(archive_repo.path()) 439 .output() 440 .map_err(|error| format!("stage archive revision: {error}"))?; 441 if !fetch.status.success() { 442 return Err(format!( 443 "stage archive revision failed: {}", 444 String::from_utf8_lossy(&fetch.stderr).trim() 445 )); 446 } 447 let output = Command::new("git") 448 .args(["bundle", "create"]) 449 .arg(&temporary_path) 450 .arg("refs/heads/archive") 451 .current_dir(archive_repo.path()) 452 .output() 453 .map_err(|error| format!("create source archive: {error}"))?; 454 if !output.status.success() { 455 let _ = fs::remove_file(&temporary_path); 456 return Err(format!( 457 "create source archive failed: {}", 458 String::from_utf8_lossy(&output.stderr).trim() 459 )); 460 } 461 let bytes = read_regular_no_follow(&temporary_path)?; 462 let digest = sha256(&bytes); 463 verify_bundle(&temporary_path)?; 464 let listed = Command::new("git") 465 .args(["bundle", "list-heads"]) 466 .arg(&temporary_path) 467 .output() 468 .map_err(|error| format!("list source archive heads: {error}"))?; 469 if !listed.status.success() 470 || String::from_utf8_lossy(&listed.stdout).trim() 471 != format!("{revision} refs/heads/archive") 472 { 473 let _ = fs::remove_file(&temporary_path); 474 return Err("source archive does not contain exactly the requested revision".to_owned()); 475 } 476 if output_path.exists() { 477 let existing = read_regular_no_follow(output_path)?; 478 let _ = fs::remove_file(&temporary_path); 479 if existing == bytes { 480 return Ok(digest); 481 } 482 return Err( 483 "immutable source archive output already exists with different bytes".to_owned(), 484 ); 485 } 486 fs::File::open(&temporary_path) 487 .and_then(|file| file.sync_all()) 488 .map_err(|error| format!("sync source archive: {error}"))?; 489 fs::rename(&temporary_path, output_path) 490 .map_err(|error| format!("install source archive: {error}"))?; 491 Ok(digest) 492 } 493 494 fn verify_bundle(path: &Path) -> Result<(), String> { 495 let verification_repo = tempfile::TempDir::new() 496 .map_err(|error| format!("create bundle verification repository: {error}"))?; 497 git(verification_repo.path(), &["init", "--bare", "--quiet"])?; 498 let output = Command::new("git") 499 .args(["bundle", "verify"]) 500 .arg(path) 501 .current_dir(verification_repo.path()) 502 .output() 503 .map_err(|error| format!("run git bundle verify: {error}"))?; 504 if output.status.success() { 505 Ok(()) 506 } else { 507 Err(format!( 508 "source archive is not a valid Git bundle: {}", 509 String::from_utf8_lossy(&output.stderr).trim() 510 )) 511 } 512 } 513 514 #[allow(clippy::too_many_arguments)] 515 pub fn artifact( 516 product: &str, 517 target: &str, 518 language: &str, 519 mode: Mode, 520 consumer_path: &Path, 521 source_path: &Path, 522 output_relative: &Path, 523 source_date_epoch: u64, 524 builder_id: &str, 525 features: &[String], 526 ) -> Result<(), String> { 527 validate_identifier(product, "product")?; 528 validate_identifier(target, "target")?; 529 validate_identifier(language, "language")?; 530 validate_identifier(builder_id, "builder id")?; 531 validate_generation_roots(product, target, language, consumer_path, source_path)?; 532 let consumer = ConsumerRoot::open(consumer_path)?; 533 let mut sorted_features = features.iter().map(String::as_str).collect::<Vec<_>>(); 534 sorted_features.sort_unstable(); 535 sorted_features.dedup(); 536 for feature in &sorted_features { 537 validate_identifier(feature, "feature")?; 538 } 539 let external_names = match product { 540 "sdk" => vec!["radroots", "radroots_sdk"], 541 _ => return Err("unsupported artifact product".to_owned()), 542 }; 543 let manifest = ArtifactManifest { 544 schema: "radroots.artifact-manifest.v1", 545 product, 546 target, 547 language, 548 external_names, 549 files: Vec::new(), 550 provenance: Provenance { 551 repository: &consumer.source_lock.repository, 552 revision: &consumer.source_lock.revision, 553 architecture: &consumer.source_lock.architecture, 554 catalog_sha256: &consumer.source_lock.workspace_catalog_sha256, 555 lockfile_sha256: &consumer.source_lock.lockfile_sha256, 556 source_archive_sha256: consumer.source_lock.source_archive_sha256.as_deref(), 557 source_date_epoch, 558 builder_id, 559 features: sorted_features, 560 }, 561 }; 562 let mut bytes = serde_json::to_vec_pretty(&manifest) 563 .map_err(|error| format!("serialize artifact manifest: {error}"))?; 564 bytes.push(b'\n'); 565 let output = consumer.output(output_relative)?; 566 match mode { 567 Mode::Check => { 568 let current = read_regular_no_follow(&output)?; 569 if current == bytes { 570 Ok(()) 571 } else { 572 Err(format!("artifact manifest {} is stale", output.display())) 573 } 574 } 575 Mode::Write => atomic_write(&output, &bytes), 576 } 577 } 578 579 pub fn validate_generation_roots( 580 product: &str, 581 target: &str, 582 language: &str, 583 consumer_path: &Path, 584 source_path: &Path, 585 ) -> Result<(), String> { 586 validate_identifier(product, "product")?; 587 validate_identifier(target, "target")?; 588 validate_identifier(language, "language")?; 589 validate_artifact_route(product, target, language)?; 590 let consumer = ConsumerRoot::open(consumer_path)?; 591 if consumer.product != product { 592 return Err(format!( 593 "consumer marker {} does not match artifact product {product}", 594 consumer.product 595 )); 596 } 597 verify_source_root(source_path, &consumer.source_lock) 598 } 599 600 fn validate_artifact_route(product: &str, target: &str, language: &str) -> Result<(), String> { 601 let valid = match product { 602 "sdk" => matches!( 603 (target, language), 604 ("typescript", "typescript") 605 | ("wasm", "javascript") 606 | ("ffi", "swift") 607 | ("ffi", "kotlin") 608 ), 609 _ => false, 610 }; 611 if valid { 612 Ok(()) 613 } else { 614 Err(format!( 615 "unsupported artifact route {product}/{target}/{language}" 616 )) 617 } 618 } 619 620 fn verify_source_root(path: &Path, source_lock: &SourceLock) -> Result<(), String> { 621 require_absolute_real_directory(path, "source root")?; 622 if git_stdout(path, &["rev-parse", "HEAD"])?.trim() != source_lock.revision { 623 return Err("source root revision does not match source lock".to_owned()); 624 } 625 let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?; 626 if sha256(&catalog) != source_lock.workspace_catalog_sha256 { 627 return Err("source root catalog does not match source lock".to_owned()); 628 } 629 git(path, &["diff", "--quiet", "--no-ext-diff"])?; 630 git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?; 631 verify_untracked_cache_paths(path) 632 } 633 634 fn verify_untracked_cache_paths(path: &Path) -> Result<(), String> { 635 let status = git_stdout(path, &["status", "--porcelain", "--untracked-files=all"])?; 636 for line in status.lines() { 637 if line != "?? .radroots-source-cache.v1.toml" { 638 return Err(format!("source tree contains ungoverned change {line}")); 639 } 640 } 641 Ok(()) 642 } 643 644 fn parse_source_lock(path: &Path) -> Result<SourceLock, String> { 645 let bytes = read_regular_no_follow(path)?; 646 let raw = std::str::from_utf8(&bytes) 647 .map_err(|error| format!("source lock is not UTF-8: {error}"))?; 648 toml::from_str(raw).map_err(|error| format!("parse source lock {}: {error}", path.display())) 649 } 650 651 fn validate_source_lock(source_lock: &SourceLock) -> Result<(), String> { 652 if source_lock.schema != "radroots.lib.source-lock.v1" 653 || source_lock.repository != REPOSITORY 654 || source_lock.architecture != ARCHITECTURE 655 || source_lock.version != VERSION 656 { 657 return Err( 658 "source lock identity, repository, architecture, or version drifted".to_owned(), 659 ); 660 } 661 validate_oid(&source_lock.revision, "source lock revision")?; 662 validate_sha256( 663 &source_lock.workspace_catalog_sha256, 664 "source lock catalog digest", 665 )?; 666 validate_sha256(&source_lock.lockfile_sha256, "source lock lockfile digest")?; 667 validate_relative_path(Path::new(&source_lock.lockfile), "source lock lockfile")?; 668 if let Some(digest) = &source_lock.source_archive_sha256 { 669 validate_sha256(digest, "source lock archive digest")?; 670 } 671 Ok(()) 672 } 673 674 fn validate_consumer_files(root: &Path, source_lock: &SourceLock) -> Result<(), String> { 675 let lockfile_relative = Path::new(&source_lock.lockfile); 676 ensure_no_symlink_components(root, lockfile_relative)?; 677 let lockfile = root.join(lockfile_relative); 678 let lockfile_bytes = read_regular_no_follow(&lockfile)?; 679 if sha256(&lockfile_bytes) != source_lock.lockfile_sha256 { 680 return Err("consumer Cargo.lock digest drifted".to_owned()); 681 } 682 let lockfile_raw = std::str::from_utf8(&lockfile_bytes) 683 .map_err(|error| format!("consumer Cargo.lock is not UTF-8: {error}"))?; 684 let cargo_lock = toml::from_str::<CargoLock>(lockfile_raw) 685 .map_err(|error| format!("parse consumer Cargo.lock: {error}"))?; 686 let expected_source = format!( 687 "git+{}?rev={}#{}", 688 source_lock.repository, source_lock.revision, source_lock.revision 689 ); 690 let mut lock_source_count = 0_usize; 691 for source in cargo_lock 692 .package 693 .iter() 694 .filter_map(|package| package.source.as_deref()) 695 .filter(|source| source.contains("radrootslabs/lib")) 696 { 697 lock_source_count += 1; 698 if source != expected_source { 699 return Err("consumer Cargo.lock contains a mixed or floating lib source".to_owned()); 700 } 701 } 702 if lock_source_count == 0 { 703 return Err("consumer Cargo.lock contains no canonical lib source".to_owned()); 704 } 705 let mut manifests = Vec::new(); 706 collect_manifests(root, root, &mut manifests)?; 707 if manifests.is_empty() { 708 return Err("consumer root contains no Cargo manifest".to_owned()); 709 } 710 let mut dependency_count = 0_usize; 711 for manifest in manifests { 712 let bytes = read_regular_no_follow(&manifest)?; 713 let raw = std::str::from_utf8(&bytes) 714 .map_err(|error| format!("consumer manifest is not UTF-8: {error}"))?; 715 let value = toml::from_str::<toml::Value>(raw) 716 .map_err(|error| format!("parse consumer manifest {}: {error}", manifest.display()))?; 717 validate_manifest_value(&value, source_lock, &mut dependency_count)?; 718 } 719 if dependency_count == 0 { 720 return Err("consumer manifests contain no canonical lib dependency".to_owned()); 721 } 722 Ok(()) 723 } 724 725 fn collect_manifests(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> { 726 for entry in fs::read_dir(current) 727 .map_err(|error| format!("read consumer directory {}: {error}", current.display()))? 728 { 729 let entry = entry.map_err(|error| format!("read consumer directory entry: {error}"))?; 730 let file_type = entry 731 .file_type() 732 .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?; 733 if file_type.is_symlink() { 734 return Err(format!( 735 "consumer tree contains symlink {}", 736 entry.path().display() 737 )); 738 } 739 let name = entry.file_name(); 740 if file_type.is_dir() { 741 if matches!(name.to_str(), Some(".git" | ".radroots")) 742 || is_build_output_directory(name.as_os_str()) 743 { 744 continue; 745 } 746 collect_manifests(root, &entry.path(), output)?; 747 } else if name == "Cargo.toml" { 748 entry 749 .path() 750 .strip_prefix(root) 751 .map_err(|_| "consumer manifest escaped root".to_owned())?; 752 output.push(entry.path()); 753 } 754 } 755 output.sort(); 756 Ok(()) 757 } 758 759 fn validate_manifest_value( 760 value: &toml::Value, 761 source_lock: &SourceLock, 762 dependency_count: &mut usize, 763 ) -> Result<(), String> { 764 match value { 765 toml::Value::Table(table) => { 766 let structured_lib_url = table 767 .get("git") 768 .and_then(toml::Value::as_str) 769 .filter(|git| git.contains("radrootslabs/lib")); 770 if let Some(git) = structured_lib_url { 771 *dependency_count += 1; 772 if git != source_lock.repository 773 || table.get("rev").and_then(toml::Value::as_str) 774 != Some(source_lock.revision.as_str()) 775 || table.get("version").and_then(toml::Value::as_str) != Some("=0.1.0-alpha") 776 || table.contains_key("branch") 777 || table.contains_key("tag") 778 || table.contains_key("path") 779 { 780 return Err( 781 "consumer manifest contains a mixed or floating lib dependency".to_owned(), 782 ); 783 } 784 } 785 for (key, child) in table { 786 if structured_lib_url.is_some() && key == "git" { 787 continue; 788 } 789 if key == "patch" && format!("{child:?}").contains("radrootslabs/lib") { 790 return Err("consumer manifest contains a lib patch override".to_owned()); 791 } 792 validate_manifest_value(child, source_lock, dependency_count)?; 793 } 794 } 795 toml::Value::Array(values) => { 796 for child in values { 797 validate_manifest_value(child, source_lock, dependency_count)?; 798 } 799 } 800 toml::Value::String(value) if value.contains("radrootslabs/lib") => { 801 return Err("consumer manifest contains an unstructured lib source".to_owned()); 802 } 803 _ => {} 804 } 805 Ok(()) 806 } 807 808 pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { 809 if bytes.contains(&b'\r') || !bytes.ends_with(b"\n") { 810 return Err("generated text must use LF and end with one newline".to_owned()); 811 } 812 atomic_write_bytes(path, bytes) 813 } 814 815 pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> { 816 let parent = path 817 .parent() 818 .ok_or_else(|| format!("output {} has no parent", path.display()))?; 819 create_directories_no_follow(parent)?; 820 if let Ok(metadata) = fs::symlink_metadata(path) { 821 if metadata.file_type().is_symlink() || !metadata.is_file() { 822 return Err(format!("output {} must be a regular file", path.display())); 823 } 824 if fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))? == bytes { 825 return Ok(()); 826 } 827 } 828 let mut temporary = tempfile::NamedTempFile::new_in(parent) 829 .map_err(|error| format!("stage output in {}: {error}", parent.display()))?; 830 temporary 831 .write_all(bytes) 832 .map_err(|error| format!("stage output {}: {error}", path.display()))?; 833 temporary 834 .as_file() 835 .sync_all() 836 .map_err(|error| format!("sync staged output {}: {error}", path.display()))?; 837 temporary 838 .persist(path) 839 .map_err(|error| format!("replace output {}: {}", path.display(), error.error))?; 840 Ok(()) 841 } 842 843 fn create_directories_no_follow(path: &Path) -> Result<(), String> { 844 let mut current = PathBuf::new(); 845 for component in path.components() { 846 current.push(component.as_os_str()); 847 match fs::symlink_metadata(¤t) { 848 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { 849 return Err(format!( 850 "output parent {} is not a real directory", 851 current.display() 852 )); 853 } 854 Ok(_) => {} 855 Err(error) if error.kind() == std::io::ErrorKind::NotFound => { 856 fs::create_dir(¤t).map_err(|error| { 857 format!("create output directory {}: {error}", current.display()) 858 })?; 859 } 860 Err(error) => { 861 return Err(format!( 862 "inspect output directory {}: {error}", 863 current.display() 864 )); 865 } 866 } 867 } 868 Ok(()) 869 } 870 871 fn ensure_no_symlink_components(root: &Path, relative: &Path) -> Result<(), String> { 872 let mut current = root.to_path_buf(); 873 for component in relative.components() { 874 current.push(component.as_os_str()); 875 match fs::symlink_metadata(¤t) { 876 Ok(metadata) if metadata.file_type().is_symlink() => { 877 return Err(format!( 878 "artifact path contains symlink {}", 879 current.display() 880 )); 881 } 882 Ok(_) => {} 883 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, 884 Err(error) => { 885 return Err(format!( 886 "inspect artifact path {}: {error}", 887 current.display() 888 )); 889 } 890 } 891 } 892 Ok(()) 893 } 894 895 fn read_regular_no_follow(path: &Path) -> Result<Vec<u8>, String> { 896 let metadata = fs::symlink_metadata(path) 897 .map_err(|error| format!("inspect {}: {error}", path.display()))?; 898 if metadata.file_type().is_symlink() || !metadata.is_file() { 899 return Err(format!( 900 "{} must be a regular non-symlink file", 901 path.display() 902 )); 903 } 904 fs::read(path).map_err(|error| format!("read {}: {error}", path.display())) 905 } 906 907 fn require_absolute_real_directory(path: &Path, label: &str) -> Result<(), String> { 908 if !path.is_absolute() { 909 return Err(format!("{label} must be absolute")); 910 } 911 let metadata = fs::symlink_metadata(path) 912 .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?; 913 if metadata.file_type().is_symlink() || !metadata.is_dir() { 914 return Err(format!("{label} must be a real directory")); 915 } 916 Ok(()) 917 } 918 919 fn validate_relative_path(path: &Path, label: &str) -> Result<(), String> { 920 if path.as_os_str().is_empty() 921 || path.is_absolute() 922 || path 923 .components() 924 .any(|component| !matches!(component, Component::Normal(_))) 925 { 926 return Err(format!("{label} must be a safe relative path")); 927 } 928 Ok(()) 929 } 930 931 fn validate_identifier(value: &str, label: &str) -> Result<(), String> { 932 if value.is_empty() 933 || !value.bytes().all(|byte| { 934 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'/') 935 }) 936 { 937 return Err(format!("{label} contains unsupported characters")); 938 } 939 Ok(()) 940 } 941 942 fn validate_oid(value: &str, label: &str) -> Result<(), String> { 943 if value.len() != 40 944 || !value 945 .bytes() 946 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 947 { 948 return Err(format!("{label} must be lowercase full 40-hex")); 949 } 950 Ok(()) 951 } 952 953 fn validate_sha256(value: &str, label: &str) -> Result<(), String> { 954 if value.len() != 64 955 || !value 956 .bytes() 957 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 958 { 959 return Err(format!("{label} must be lowercase 64-hex")); 960 } 961 Ok(()) 962 } 963 964 fn sha256(bytes: &[u8]) -> String { 965 format!("{:x}", Sha256::digest(bytes)) 966 } 967 968 fn git(root: &Path, args: &[&str]) -> Result<(), String> { 969 let output = Command::new("git") 970 .args(args) 971 .current_dir(root) 972 .output() 973 .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; 974 if output.status.success() { 975 Ok(()) 976 } else { 977 Err(format!( 978 "git {} failed: {}", 979 args.join(" "), 980 String::from_utf8_lossy(&output.stderr).trim() 981 )) 982 } 983 } 984 985 fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> { 986 let output = Command::new("git") 987 .args(args) 988 .current_dir(root) 989 .output() 990 .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; 991 if !output.status.success() { 992 return Err(format!( 993 "git {} failed: {}", 994 args.join(" "), 995 String::from_utf8_lossy(&output.stderr).trim() 996 )); 997 } 998 String::from_utf8(output.stdout) 999 .map_err(|error| format!("git {} output is not UTF-8: {error}", args.join(" "))) 1000 } 1001 1002 fn set_readonly_tree(root: &Path) -> Result<(), String> { 1003 for entry in walk(root)? { 1004 let metadata = fs::symlink_metadata(&entry) 1005 .map_err(|error| format!("inspect cache path {}: {error}", entry.display()))?; 1006 if metadata.file_type().is_symlink() { 1007 return Err(format!("source cache contains symlink {}", entry.display())); 1008 } 1009 let mut permissions = metadata.permissions(); 1010 permissions.set_readonly(true); 1011 fs::set_permissions(&entry, permissions) 1012 .map_err(|error| format!("protect cache path {}: {error}", entry.display()))?; 1013 } 1014 Ok(()) 1015 } 1016 1017 fn walk(root: &Path) -> Result<Vec<PathBuf>, String> { 1018 let mut pending = vec![root.to_path_buf()]; 1019 let mut output = Vec::new(); 1020 while let Some(path) = pending.pop() { 1021 output.push(path.clone()); 1022 if fs::symlink_metadata(&path) 1023 .map_err(|error| format!("inspect {}: {error}", path.display()))? 1024 .is_dir() 1025 { 1026 for entry in 1027 fs::read_dir(&path).map_err(|error| format!("read {}: {error}", path.display()))? 1028 { 1029 pending.push( 1030 entry 1031 .map_err(|error| format!("read directory entry: {error}"))? 1032 .path(), 1033 ); 1034 } 1035 } 1036 } 1037 output.sort_by_key(|path| std::cmp::Reverse(path.components().count())); 1038 Ok(output) 1039 } 1040 1041 #[cfg(test)] 1042 mod tests { 1043 use super::*; 1044 1045 struct Fixture { 1046 _root: tempfile::TempDir, 1047 source: PathBuf, 1048 consumer: PathBuf, 1049 cache: PathBuf, 1050 source_lock: SourceLock, 1051 } 1052 1053 impl Fixture { 1054 fn new(product: &str) -> Self { 1055 let root = tempfile::TempDir::new().expect("fixture root"); 1056 let source = root.path().join("source"); 1057 let consumer = root.path().join("consumer"); 1058 let cache = root.path().join("cache"); 1059 fs::create_dir(&source).expect("source"); 1060 fs::create_dir(&consumer).expect("consumer"); 1061 fs::create_dir(&cache).expect("cache"); 1062 git(&source, &["init", "--initial-branch=master"]).expect("init"); 1063 git(&source, &["config", "user.name", "Build Control Fixture"]).expect("name"); 1064 git( 1065 &source, 1066 &["config", "user.email", "build-control@radroots.org"], 1067 ) 1068 .expect("email"); 1069 fs::create_dir_all(source.join("contracts/crates")).expect("contracts"); 1070 fs::write( 1071 source.join(CATALOG_RELATIVE), 1072 "schema = \"radroots.workspace.catalog.v2\"\n", 1073 ) 1074 .expect("catalog"); 1075 fs::create_dir_all(source.join("src")).expect("source crate"); 1076 fs::write( 1077 source.join("Cargo.toml"), 1078 "[package]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\nedition = \"2024\"\n", 1079 ) 1080 .expect("source manifest"); 1081 fs::write(source.join("src/lib.rs"), "pub const READY: bool = true;\n") 1082 .expect("source library"); 1083 fs::write( 1084 source.join("Cargo.lock"), 1085 "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\n", 1086 ) 1087 .expect("source lockfile"); 1088 git(&source, &["add", "--all"]).expect("add"); 1089 git(&source, &["commit", "-m", "seed source fixture"]).expect("commit"); 1090 let revision = git_stdout(&source, &["rev-parse", "HEAD"]) 1091 .expect("revision") 1092 .trim() 1093 .to_owned(); 1094 let catalog_sha256 = 1095 sha256(&fs::read(source.join(CATALOG_RELATIVE)).expect("read source catalog")); 1096 fs::write(consumer.join(CONSUMER_MARKER), format!("{product}\n")).expect("marker"); 1097 fs::write( 1098 consumer.join("Cargo.toml"), 1099 format!( 1100 "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nradroots_core = {{ git = \"{REPOSITORY}\", rev = \"{revision}\", version = \"=0.1.0-alpha\" }}\n" 1101 ), 1102 ) 1103 .expect("manifest"); 1104 let consumer_lock = format!( 1105 "version = 4\n\n[[package]]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\nsource = \"git+{REPOSITORY}?rev={revision}#{revision}\"\n" 1106 ); 1107 fs::write(consumer.join("Cargo.lock"), &consumer_lock).expect("consumer lockfile"); 1108 let source_lock = SourceLock { 1109 schema: "radroots.lib.source-lock.v1".to_owned(), 1110 repository: REPOSITORY.to_owned(), 1111 revision, 1112 architecture: ARCHITECTURE.to_owned(), 1113 workspace_catalog_sha256: catalog_sha256, 1114 version: VERSION.to_owned(), 1115 source_archive_sha256: None, 1116 lockfile: default_consumer_lockfile(), 1117 lockfile_sha256: sha256(consumer_lock.as_bytes()), 1118 }; 1119 fs::write( 1120 consumer.join(SOURCE_LOCK_NAME), 1121 toml::to_string(&source_lock).expect("serialize source lock"), 1122 ) 1123 .expect("source lock"); 1124 Self { 1125 _root: root, 1126 source, 1127 consumer, 1128 cache, 1129 source_lock, 1130 } 1131 } 1132 } 1133 1134 #[test] 1135 fn source_lock_and_consumer_root_fail_closed() { 1136 let fixture = Fixture::new("sdk"); 1137 ConsumerRoot::open(&fixture.consumer).expect("valid consumer"); 1138 let mut invalid = fixture.source_lock.clone(); 1139 invalid.revision = "short".to_owned(); 1140 assert!(validate_source_lock(&invalid).is_err()); 1141 1142 fs::write( 1143 fixture.consumer.join("Cargo.toml"), 1144 "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nradroots_core = { git = \"https://github.com/radrootslabs/lib\", branch = \"master\", version = \"*\" }\n", 1145 ) 1146 .expect("floating manifest"); 1147 assert!(ConsumerRoot::open(&fixture.consumer).is_err()); 1148 } 1149 1150 #[test] 1151 fn source_lock_accepts_services_without_creating_artifact_routes() { 1152 for product in ["myc", "rhi"] { 1153 let fixture = Fixture::new(product); 1154 let consumer = ConsumerRoot::open(&fixture.consumer).expect("valid service consumer"); 1155 assert_eq!(consumer.product, product); 1156 assert!(validate_artifact_route(product, "linux", "rust").is_err()); 1157 } 1158 } 1159 1160 #[test] 1161 fn retired_application_consumers_and_artifact_routes_fail_closed() { 1162 for product in ["mobile", "tera"] { 1163 let fixture = Fixture::new(product); 1164 assert!(ConsumerRoot::open(&fixture.consumer).is_err()); 1165 for (target, language) in [ 1166 ("ios", "swift"), 1167 ("android", "kotlin"), 1168 ("wasm", "javascript"), 1169 ] { 1170 assert!(validate_artifact_route(product, target, language).is_err()); 1171 assert!( 1172 artifact( 1173 product, 1174 target, 1175 language, 1176 Mode::Write, 1177 &fixture.consumer, 1178 &fixture.source, 1179 Path::new("generated/retired.json"), 1180 1, 1181 "fixture", 1182 &[], 1183 ) 1184 .is_err() 1185 ); 1186 } 1187 assert!(!fixture.consumer.join("generated").exists()); 1188 } 1189 } 1190 1191 #[test] 1192 fn source_lock_supports_a_contained_nested_lockfile() { 1193 let mut fixture = Fixture::new("sdk"); 1194 let core = fixture.consumer.join("core"); 1195 fs::create_dir(&core).expect("create nested capsule"); 1196 fs::rename(fixture.consumer.join("Cargo.lock"), core.join("Cargo.lock")) 1197 .expect("move lockfile"); 1198 fixture.source_lock.lockfile = "core/Cargo.lock".to_owned(); 1199 fs::write( 1200 fixture.consumer.join(SOURCE_LOCK_NAME), 1201 toml::to_string(&fixture.source_lock).expect("serialize nested source lock"), 1202 ) 1203 .expect("write nested source lock"); 1204 1205 ConsumerRoot::open(&fixture.consumer).expect("nested lockfile is valid"); 1206 1207 fixture.source_lock.lockfile = "../Cargo.lock".to_owned(); 1208 fs::write( 1209 fixture.consumer.join(SOURCE_LOCK_NAME), 1210 toml::to_string(&fixture.source_lock).expect("serialize escaping source lock"), 1211 ) 1212 .expect("write escaping source lock"); 1213 assert!(ConsumerRoot::open(&fixture.consumer).is_err()); 1214 } 1215 1216 #[test] 1217 fn consumer_manifest_discovery_skips_swiftpm_build_output_only() { 1218 let fixture = Fixture::new("sdk"); 1219 let swiftpm_checkout = fixture.consumer.join(".build/checkouts/dependency"); 1220 fs::create_dir_all(&swiftpm_checkout).expect("SwiftPM checkout directory"); 1221 fs::write(swiftpm_checkout.join("Cargo.toml"), "not valid TOML") 1222 .expect("SwiftPM build manifest"); 1223 ConsumerRoot::open(&fixture.consumer).expect("SwiftPM build output is excluded"); 1224 1225 let source_lookalike = fixture.consumer.join(".builder"); 1226 fs::create_dir(&source_lookalike).expect("source lookalike directory"); 1227 fs::write(source_lookalike.join("Cargo.toml"), "not valid TOML") 1228 .expect("source lookalike manifest"); 1229 assert!(ConsumerRoot::open(&fixture.consumer).is_err()); 1230 } 1231 1232 #[test] 1233 fn materialization_reuses_verified_cache_and_rejects_tampering() { 1234 let fixture = Fixture::new("sdk"); 1235 let consumer = ConsumerRoot::open(&fixture.consumer).expect("consumer"); 1236 let cached = materialize_from( 1237 &consumer, 1238 &fixture.cache, 1239 false, 1240 fixture.source.to_str().expect("source path"), 1241 ) 1242 .expect("prefetch"); 1243 assert_eq!( 1244 materialize_from( 1245 &consumer, 1246 &fixture.cache, 1247 true, 1248 fixture.source.to_str().expect("source path"), 1249 ) 1250 .expect("offline reuse"), 1251 cached 1252 ); 1253 let frozen_target = fixture._root.path().join("frozen-target"); 1254 let frozen = Command::new("cargo") 1255 .args(["check", "--offline", "--frozen", "--locked"]) 1256 .env("CARGO_TARGET_DIR", &frozen_target) 1257 .current_dir(&cached) 1258 .output() 1259 .expect("run frozen offline source smoke"); 1260 assert!( 1261 frozen.status.success(), 1262 "frozen offline source smoke failed: {}", 1263 String::from_utf8_lossy(&frozen.stderr) 1264 ); 1265 let catalog = cached.join(CATALOG_RELATIVE); 1266 make_path_writable(&catalog).expect("make catalog writable"); 1267 fs::write(&catalog, "tampered\n").expect("tamper"); 1268 assert!(verify_cache(&cached, &fixture.source_lock).is_err()); 1269 make_writable(&cached); 1270 } 1271 1272 #[test] 1273 fn artifact_manifests_are_deterministic_and_route_checked() { 1274 let fixture = Fixture::new("sdk"); 1275 let output = Path::new("generated/artifact-manifest.json"); 1276 artifact( 1277 "sdk", 1278 "typescript", 1279 "typescript", 1280 Mode::Write, 1281 &fixture.consumer, 1282 &fixture.source, 1283 output, 1284 1_700_000_000, 1285 "fixture_builder", 1286 &["zeta".to_owned(), "alpha".to_owned(), "alpha".to_owned()], 1287 ) 1288 .expect("write artifact manifest"); 1289 artifact( 1290 "sdk", 1291 "typescript", 1292 "typescript", 1293 Mode::Check, 1294 &fixture.consumer, 1295 &fixture.source, 1296 output, 1297 1_700_000_000, 1298 "fixture_builder", 1299 &["alpha".to_owned(), "zeta".to_owned()], 1300 ) 1301 .expect("check artifact manifest"); 1302 assert!( 1303 validate_artifact_route("mobile", "ios", "kotlin").is_err(), 1304 "unsupported target/language must fail" 1305 ); 1306 assert!(validate_artifact_route("sdk", "wasm", "javascript").is_ok()); 1307 assert!(validate_artifact_route("sdk", "ffi", "swift").is_ok()); 1308 assert!(validate_artifact_route("sdk", "ffi", "kotlin").is_ok()); 1309 assert!(validate_artifact_route("sdk", "wasm", "typescript").is_err()); 1310 assert!( 1311 artifact( 1312 "sdk", 1313 "typescript", 1314 "typescript", 1315 Mode::Write, 1316 &fixture.consumer, 1317 &fixture.source, 1318 Path::new("../escape"), 1319 1, 1320 "fixture_builder", 1321 &[], 1322 ) 1323 .is_err() 1324 ); 1325 } 1326 1327 #[test] 1328 fn atomic_outputs_reject_unsafe_text_and_path_collisions() { 1329 let root = tempfile::TempDir::new().expect("output fixture"); 1330 let output = root.path().join("generated/output.json"); 1331 atomic_write(&output, b"prior\n").expect("initial output"); 1332 #[cfg(unix)] 1333 let initial_inode = { 1334 use std::os::unix::fs::MetadataExt; 1335 fs::metadata(&output).expect("initial metadata").ino() 1336 }; 1337 atomic_write(&output, b"prior\n").expect("identical no-op"); 1338 #[cfg(unix)] 1339 { 1340 use std::os::unix::fs::MetadataExt; 1341 assert_eq!( 1342 fs::metadata(&output).expect("no-op metadata").ino(), 1343 initial_inode 1344 ); 1345 } 1346 assert!(atomic_write(&output, b"missing newline").is_err()); 1347 assert!(atomic_write(&output, b"windows\r\n").is_err()); 1348 assert_eq!( 1349 fs::read(&output).expect("prior output retained"), 1350 b"prior\n" 1351 ); 1352 let directory_output = root.path().join("directory-output"); 1353 fs::create_dir_all(&directory_output).expect("directory collision"); 1354 assert!(atomic_write(&directory_output, b"{}\n").is_err()); 1355 1356 #[cfg(unix)] 1357 { 1358 use std::os::unix::fs::symlink; 1359 1360 let symlink_root = tempfile::TempDir::new().expect("symlink fixture"); 1361 let outside = tempfile::TempDir::new().expect("outside fixture"); 1362 symlink(outside.path(), symlink_root.path().join("generated")) 1363 .expect("output parent symlink"); 1364 assert!( 1365 atomic_write(&symlink_root.path().join("generated/output.json"), b"{}\n").is_err() 1366 ); 1367 } 1368 } 1369 1370 #[test] 1371 fn source_archive_round_trip_is_digest_bound_and_immutable() { 1372 let fixture = Fixture::new("sdk"); 1373 let archive = fixture._root.path().join("archives/source.bundle"); 1374 let digest = 1375 create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive) 1376 .expect("create archive"); 1377 verify_source_archive(&archive, &digest).expect("verify archive"); 1378 assert_eq!( 1379 create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive,) 1380 .expect("identical archive no-op"), 1381 digest 1382 ); 1383 assert!(verify_source_archive(&archive, &"0".repeat(64)).is_err()); 1384 assert!(verify_source_archive(Path::new("relative.bundle"), &digest).is_err()); 1385 } 1386 1387 #[cfg(unix)] 1388 #[test] 1389 fn consumer_and_source_roots_reject_symlinks() { 1390 use std::os::unix::fs::symlink; 1391 1392 let fixture = Fixture::new("sdk"); 1393 let consumer_link = fixture._root.path().join("consumer-link"); 1394 symlink(&fixture.consumer, &consumer_link).expect("consumer symlink"); 1395 assert!(ConsumerRoot::open(&consumer_link).is_err()); 1396 1397 let source_link = fixture._root.path().join("source-link"); 1398 symlink(&fixture.source, &source_link).expect("source symlink"); 1399 assert!(verify_source_root(&source_link, &fixture.source_lock).is_err()); 1400 } 1401 1402 #[test] 1403 fn checked_in_group_plan_is_explicit_and_active_only() { 1404 let plan = group_plan( 1405 &crate::workspace_root(), 1406 "public_native", 1407 Operation::Check, 1408 false, 1409 ) 1410 .expect("public native plan"); 1411 assert!(plan.iter().any(|arg| arg == "radroots_core")); 1412 assert!(plan.iter().any(|arg| arg == "radroots_sdk")); 1413 assert!(!plan.iter().any(|arg| arg == "--workspace")); 1414 assert!(group_plan(&crate::workspace_root(), "missing", Operation::Check, false).is_err()); 1415 } 1416 1417 fn make_writable(root: &Path) { 1418 if let Ok(paths) = walk(root) { 1419 for path in paths { 1420 let _ = make_path_writable(&path); 1421 } 1422 } 1423 } 1424 1425 fn make_path_writable(path: &Path) -> Result<(), std::io::Error> { 1426 let mut permissions = fs::metadata(path)?.permissions(); 1427 #[cfg(unix)] 1428 { 1429 use std::os::unix::fs::PermissionsExt; 1430 permissions.set_mode(permissions.mode() | 0o200); 1431 } 1432 #[cfg(not(unix))] 1433 permissions.set_readonly(false); 1434 fs::set_permissions(path, permissions) 1435 } 1436 }