service_source_lock_command.rs (72542B)
1 use std::{ 2 collections::BTreeSet, 3 ffi::OsStr, 4 fmt, fs, 5 io::{Read as _, Seek as _, SeekFrom, Write as _}, 6 path::{Path, PathBuf}, 7 process::{Command, Stdio}, 8 }; 9 10 use serde::Deserialize; 11 use sha2::{Digest as _, Sha256}; 12 use tempfile::{NamedTempFile, TempDir}; 13 use walkdir::WalkDir; 14 15 use crate::build_output::is_build_output_directory; 16 use crate::service_source_lock::{ 17 ContractVersions, LIB_REPOSITORY, LOCK_FILENAME, NixMaterialParts, NixMaterialState, 18 PREDECESSOR_LOCK_FILENAME, ServiceSourceLockParts, ServiceSourceLockV2, 19 validate_deferred_nix_material, 20 }; 21 22 const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml"; 23 const CARGO_MANIFEST: &str = "Cargo.toml"; 24 const CARGO_LOCK: &str = "Cargo.lock"; 25 const FLAKE_NIX: &str = "flake.nix"; 26 const FLAKE_LOCK: &str = "flake.lock"; 27 const RUST_TOOLCHAIN: &str = "rust-toolchain.toml"; 28 const LIB_VERSION_REQUIREMENT: &str = "=0.1.0-alpha"; 29 const HOST_PACKAGE: &str = "radroots_service_host"; 30 const HOST_FEATURE_PROFILE: &str = "service-host"; 31 const RUST_VERSION: &str = "1.97.1"; 32 const MAX_MANIFEST_BYTES: usize = 1_048_576; 33 const MAX_CARGO_LOCK_BYTES: usize = 16_777_216; 34 const MAX_FLAKE_LOCK_BYTES: usize = 4_194_304; 35 const MAX_FLAKE_NIX_BYTES: usize = 1_048_576; 36 const MAX_TOOLCHAIN_BYTES: usize = 65_536; 37 const MAX_CATALOG_BYTES: usize = 4_194_304; 38 const MAX_GIT_OUTPUT_BYTES: usize = 65_536; 39 const MAX_ARCHIVE_BYTES: u64 = 1_073_741_824; 40 const MAX_TREE_ENTRIES: usize = 16_384; 41 const MAX_MANIFESTS: usize = 512; 42 43 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 44 pub(crate) enum CommandMode { 45 Check, 46 Write, 47 } 48 49 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 50 enum CommandError { 51 InvalidServiceRoot, 52 DirtyServiceSource, 53 InvalidServiceMetadata, 54 InvalidCargoManifest, 55 InvalidCargoLock, 56 InvalidNixMaterial, 57 InvalidFlakeLock, 58 InvalidToolchain, 59 InvalidSourceArchive, 60 UnreachableRevision, 61 InvalidSourceLock, 62 StaleSourceLock, 63 WriteFailure, 64 } 65 66 impl fmt::Display for CommandError { 67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 68 formatter.write_str(match self { 69 Self::InvalidServiceRoot => "service source-lock root is invalid", 70 Self::DirtyServiceSource => "service source contains an ungoverned change", 71 Self::InvalidServiceMetadata => "service source-lock metadata is invalid", 72 Self::InvalidCargoManifest => "service Cargo manifest dependency is invalid", 73 Self::InvalidCargoLock => "service Cargo lock is invalid", 74 Self::InvalidNixMaterial => "service deferred Nix material is invalid", 75 Self::InvalidFlakeLock => "service flake lock is invalid", 76 Self::InvalidToolchain => "service Rust toolchain is invalid", 77 Self::InvalidSourceArchive => "Lib source archive is invalid", 78 Self::UnreachableRevision => "Lib revision is not remotely reachable", 79 Self::InvalidSourceLock => "service source lock is invalid", 80 Self::StaleSourceLock => "service source lock is stale", 81 Self::WriteFailure => "service source lock could not be updated", 82 }) 83 } 84 } 85 86 impl std::error::Error for CommandError {} 87 88 #[derive(Clone, Debug, Eq, PartialEq)] 89 struct ServiceMetadata { 90 service: String, 91 nix_material: NixMaterialState, 92 contract_versions: ContractVersions, 93 } 94 95 #[derive(Clone, Debug, Eq, PartialEq)] 96 enum NixMaterialEvidence { 97 Absent, 98 Deferred { 99 lib_revision: String, 100 flake_lock_sha256: String, 101 }, 102 } 103 104 impl NixMaterialEvidence { 105 fn as_parts(&self) -> NixMaterialParts<'_> { 106 match self { 107 Self::Absent => NixMaterialParts::Absent, 108 Self::Deferred { 109 lib_revision, 110 flake_lock_sha256, 111 } => NixMaterialParts::Deferred { 112 lib_revision, 113 flake_lock_sha256, 114 }, 115 } 116 } 117 118 fn revision(&self) -> Option<&str> { 119 match self { 120 Self::Absent => None, 121 Self::Deferred { lib_revision, .. } => Some(lib_revision), 122 } 123 } 124 } 125 126 #[derive(Debug, Deserialize)] 127 struct CargoLockDocument { 128 #[serde(default)] 129 package: Vec<CargoLockPackage>, 130 } 131 132 #[derive(Debug, Deserialize)] 133 struct CargoLockPackage { 134 name: String, 135 version: String, 136 source: Option<String>, 137 } 138 139 struct ArchiveEvidence { 140 revision: String, 141 archive_sha256: String, 142 catalog_sha256: String, 143 package_names: BTreeSet<String>, 144 } 145 146 trait RevisionReachability { 147 fn verify(&self, revision: &str) -> Result<(), CommandError>; 148 } 149 150 struct PublicLibRemote; 151 152 impl RevisionReachability for PublicLibRemote { 153 fn verify(&self, revision: &str) -> Result<(), CommandError> { 154 let repository = TempDir::new().map_err(|_| CommandError::UnreachableRevision)?; 155 git_status(repository.path(), ["init", "--bare", "--quiet"]) 156 .map_err(|_| CommandError::UnreachableRevision)?; 157 let status = Command::new("git") 158 .args(["fetch", "--quiet", "--no-tags", "--depth=1", LIB_REPOSITORY]) 159 .arg(revision) 160 .current_dir(repository.path()) 161 .stdin(Stdio::null()) 162 .stdout(Stdio::null()) 163 .stderr(Stdio::null()) 164 .status() 165 .map_err(|_| CommandError::UnreachableRevision)?; 166 if !status.success() { 167 return Err(CommandError::UnreachableRevision); 168 } 169 git_status(repository.path(), ["cat-file", "-e", revision]) 170 .map_err(|_| CommandError::UnreachableRevision) 171 } 172 } 173 174 pub(crate) fn run( 175 mode: CommandMode, 176 service_root: &Path, 177 source_archive: &Path, 178 ) -> Result<(), String> { 179 run_with(mode, service_root, source_archive, &PublicLibRemote) 180 .map_err(|error| error.to_string()) 181 } 182 183 fn run_with( 184 mode: CommandMode, 185 service_root: &Path, 186 source_archive: &Path, 187 reachability: &dyn RevisionReachability, 188 ) -> Result<(), CommandError> { 189 let service_root = validate_service_root(service_root)?; 190 match fs::symlink_metadata(service_root.join(PREDECESSOR_LOCK_FILENAME)) { 191 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 192 _ => return Err(CommandError::InvalidSourceLock), 193 } 194 let initial_head = service_head(&service_root)?; 195 validate_service_cleanliness(&service_root)?; 196 197 let root_manifest = read_bounded_regular( 198 &service_root.join(CARGO_MANIFEST), 199 MAX_MANIFEST_BYTES, 200 CommandError::InvalidCargoManifest, 201 )?; 202 let root_manifest = parse_toml(&root_manifest, CommandError::InvalidCargoManifest)?; 203 let metadata = parse_service_metadata(&root_manifest)?; 204 let revision = validate_cargo_manifests(&service_root, None)?; 205 206 let nix = read_nix_material(&service_root, metadata.nix_material)?; 207 208 validate_toolchain(&service_root)?; 209 let archive = validate_archive(source_archive, &revision)?; 210 let confirmed_revision = validate_cargo_manifests(&service_root, Some(&archive.package_names))?; 211 if confirmed_revision != revision { 212 return Err(CommandError::InvalidCargoManifest); 213 } 214 let cargo_lock = read_bounded_regular( 215 &service_root.join(CARGO_LOCK), 216 MAX_CARGO_LOCK_BYTES, 217 CommandError::InvalidCargoLock, 218 )?; 219 validate_cargo_lock(&cargo_lock, &revision, &archive.package_names)?; 220 let cargo_lock_sha256 = sha256(&cargo_lock); 221 reachability.verify(&archive.revision)?; 222 if let Some(nix_revision) = nix.revision() 223 && nix_revision != archive.revision 224 { 225 reachability.verify(nix_revision)?; 226 } 227 validate_service_cleanliness(&service_root)?; 228 if service_head(&service_root)? != initial_head { 229 return Err(CommandError::DirtyServiceSource); 230 } 231 232 let desired = ServiceSourceLockV2::new(ServiceSourceLockParts { 233 service: &metadata.service, 234 revision: &archive.revision, 235 workspace_catalog_sha256: &archive.catalog_sha256, 236 source_archive_sha256: &archive.archive_sha256, 237 cargo_lock_sha256: &cargo_lock_sha256, 238 nix: nix.as_parts(), 239 contract_versions: metadata.contract_versions, 240 }) 241 .map_err(|_| CommandError::InvalidServiceMetadata)?; 242 243 let lock_path = service_root.join(LOCK_FILENAME); 244 let result = match mode { 245 CommandMode::Check => { 246 let current = read_bounded_regular(&lock_path, 4096, CommandError::InvalidSourceLock)?; 247 let current = ServiceSourceLockV2::from_canonical_bytes(¤t) 248 .map_err(|_| CommandError::InvalidSourceLock)?; 249 if current == desired { 250 Ok(()) 251 } else { 252 Err(CommandError::StaleSourceLock) 253 } 254 } 255 CommandMode::Write => { 256 atomic_write_lock(&service_root, &lock_path, desired.canonical_bytes())?; 257 let current = read_bounded_regular(&lock_path, 4096, CommandError::WriteFailure)?; 258 let current = ServiceSourceLockV2::from_canonical_bytes(¤t) 259 .map_err(|_| CommandError::WriteFailure)?; 260 if current == desired { 261 Ok(()) 262 } else { 263 Err(CommandError::WriteFailure) 264 } 265 } 266 }; 267 result?; 268 validate_service_cleanliness(&service_root)?; 269 if service_head(&service_root)? == initial_head { 270 Ok(()) 271 } else { 272 Err(CommandError::DirtyServiceSource) 273 } 274 } 275 276 fn service_head(root: &Path) -> Result<String, CommandError> { 277 let bytes = git_stdout(root, ["rev-parse", "HEAD"], 128) 278 .map_err(|_| CommandError::InvalidServiceRoot)?; 279 let revision = std::str::from_utf8(&bytes) 280 .map_err(|_| CommandError::InvalidServiceRoot)? 281 .trim(); 282 if valid_lower_hex(revision, 40) { 283 Ok(revision.to_owned()) 284 } else { 285 Err(CommandError::InvalidServiceRoot) 286 } 287 } 288 289 fn validate_service_root(path: &Path) -> Result<PathBuf, CommandError> { 290 if !path.is_absolute() { 291 return Err(CommandError::InvalidServiceRoot); 292 } 293 let metadata = fs::symlink_metadata(path).map_err(|_| CommandError::InvalidServiceRoot)?; 294 if metadata.file_type().is_symlink() || !metadata.is_dir() { 295 return Err(CommandError::InvalidServiceRoot); 296 } 297 let canonical = fs::canonicalize(path).map_err(|_| CommandError::InvalidServiceRoot)?; 298 let top = git_stdout( 299 &canonical, 300 ["rev-parse", "--show-toplevel"], 301 MAX_GIT_OUTPUT_BYTES, 302 ) 303 .map_err(|_| CommandError::InvalidServiceRoot)?; 304 let top = std::str::from_utf8(&top).map_err(|_| CommandError::InvalidServiceRoot)?; 305 let top = fs::canonicalize(top.trim()).map_err(|_| CommandError::InvalidServiceRoot)?; 306 if top == canonical { 307 Ok(canonical) 308 } else { 309 Err(CommandError::InvalidServiceRoot) 310 } 311 } 312 313 fn validate_service_cleanliness(root: &Path) -> Result<(), CommandError> { 314 git_status( 315 root, 316 [ 317 "diff", 318 "--quiet", 319 "--", 320 ".", 321 ":(exclude)radroots.service.source-lock.v2.toml", 322 ], 323 ) 324 .map_err(|_| CommandError::DirtyServiceSource)?; 325 git_status( 326 root, 327 [ 328 "diff", 329 "--cached", 330 "--quiet", 331 "--", 332 ".", 333 ":(exclude)radroots.service.source-lock.v2.toml", 334 ], 335 ) 336 .map_err(|_| CommandError::DirtyServiceSource)?; 337 let untracked = git_stdout( 338 root, 339 ["ls-files", "--others", "--exclude-standard", "-z"], 340 MAX_GIT_OUTPUT_BYTES, 341 ) 342 .map_err(|_| CommandError::DirtyServiceSource)?; 343 let mut paths = untracked 344 .split(|byte| *byte == 0) 345 .filter(|path| !path.is_empty()); 346 if paths.all(|path| path == LOCK_FILENAME.as_bytes()) { 347 Ok(()) 348 } else { 349 Err(CommandError::DirtyServiceSource) 350 } 351 } 352 353 fn parse_service_metadata(root: &toml::Value) -> Result<ServiceMetadata, CommandError> { 354 let table = root 355 .get("workspace") 356 .and_then(|value| value.get("metadata")) 357 .and_then(|value| value.get("radroots")) 358 .and_then(|value| value.get("service_source_lock")) 359 .and_then(toml::Value::as_table) 360 .ok_or(CommandError::InvalidServiceMetadata)?; 361 let expected = BTreeSet::from([ 362 "admin_contract_version", 363 "config_contract_version", 364 "host_feature_profile", 365 "nix_material", 366 "provider_contract_version", 367 "service", 368 "state_contract_version", 369 "status_contract_version", 370 ]); 371 if table.keys().map(String::as_str).collect::<BTreeSet<_>>() != expected { 372 return Err(CommandError::InvalidServiceMetadata); 373 } 374 let text = |key: &str| { 375 table 376 .get(key) 377 .and_then(toml::Value::as_str) 378 .ok_or(CommandError::InvalidServiceMetadata) 379 }; 380 let version = |key: &str| { 381 table 382 .get(key) 383 .and_then(toml::Value::as_integer) 384 .and_then(|value| u32::try_from(value).ok()) 385 .filter(|value| *value != 0) 386 .ok_or(CommandError::InvalidServiceMetadata) 387 }; 388 if text("host_feature_profile")? != HOST_FEATURE_PROFILE { 389 return Err(CommandError::InvalidServiceMetadata); 390 } 391 let nix_material = match text("nix_material")? { 392 "absent" => NixMaterialState::Absent, 393 "deferred" => NixMaterialState::Deferred, 394 _ => return Err(CommandError::InvalidServiceMetadata), 395 }; 396 Ok(ServiceMetadata { 397 service: text("service")?.to_owned(), 398 nix_material, 399 contract_versions: ContractVersions::new( 400 version("config_contract_version")?, 401 version("state_contract_version")?, 402 version("admin_contract_version")?, 403 version("status_contract_version")?, 404 version("provider_contract_version")?, 405 ), 406 }) 407 } 408 409 fn validate_cargo_manifests( 410 root: &Path, 411 lib_packages: Option<&BTreeSet<String>>, 412 ) -> Result<String, CommandError> { 413 let mut manifests = Vec::new(); 414 let mut entries = 0_usize; 415 let walker = WalkDir::new(root) 416 .follow_links(false) 417 .into_iter() 418 .filter_entry(|entry| { 419 entry.file_name() != OsStr::new(".git") && !is_build_output_directory(entry.file_name()) 420 }); 421 for entry in walker { 422 entries = entries 423 .checked_add(1) 424 .ok_or(CommandError::InvalidCargoManifest)?; 425 if entries > MAX_TREE_ENTRIES { 426 return Err(CommandError::InvalidCargoManifest); 427 } 428 let entry = entry.map_err(|_| CommandError::InvalidCargoManifest)?; 429 if entry.file_name() == OsStr::new(CARGO_MANIFEST) { 430 if entry.file_type().is_symlink() || !entry.file_type().is_file() { 431 return Err(CommandError::InvalidCargoManifest); 432 } 433 manifests.push(entry.into_path()); 434 if manifests.len() > MAX_MANIFESTS { 435 return Err(CommandError::InvalidCargoManifest); 436 } 437 } 438 } 439 manifests.sort(); 440 if manifests.is_empty() { 441 return Err(CommandError::InvalidCargoManifest); 442 } 443 let mut state = ManifestState::default(); 444 for manifest in manifests { 445 let bytes = read_bounded_regular( 446 &manifest, 447 MAX_MANIFEST_BYTES, 448 CommandError::InvalidCargoManifest, 449 )?; 450 let value = parse_toml(&bytes, CommandError::InvalidCargoManifest)?; 451 validate_manifest_node(&value, None, false, false, lib_packages, &mut state)?; 452 } 453 if state.dependencies == 0 || !state.host_dependency { 454 return Err(CommandError::InvalidCargoManifest); 455 } 456 state.revision.ok_or(CommandError::InvalidCargoManifest) 457 } 458 459 #[derive(Default)] 460 struct ManifestState { 461 revision: Option<String>, 462 dependencies: usize, 463 host_dependency: bool, 464 } 465 466 fn validate_manifest_node( 467 value: &toml::Value, 468 key: Option<&str>, 469 in_patch: bool, 470 in_dependencies: bool, 471 lib_packages: Option<&BTreeSet<String>>, 472 state: &mut ManifestState, 473 ) -> Result<(), CommandError> { 474 match value { 475 toml::Value::Table(table) => { 476 let git = table.get("git").and_then(toml::Value::as_str); 477 let package = table.get("package").and_then(toml::Value::as_str).or(key); 478 let known_lib_dependency = (in_dependencies || in_patch) 479 && package.is_some_and(|package| { 480 lib_packages.map_or(package == HOST_PACKAGE, |packages| { 481 packages.contains(package) 482 }) 483 }); 484 let canonical_lib_dependency = 485 (in_dependencies || in_patch) && git.is_some_and(|git| git == LIB_REPOSITORY); 486 if known_lib_dependency || canonical_lib_dependency { 487 let git = git.ok_or(CommandError::InvalidCargoManifest)?; 488 if in_patch 489 || git != LIB_REPOSITORY 490 || table.get("version").and_then(toml::Value::as_str) 491 != Some(LIB_VERSION_REQUIREMENT) 492 || table.contains_key("branch") 493 || table.contains_key("tag") 494 || table.contains_key("path") 495 { 496 return Err(CommandError::InvalidCargoManifest); 497 } 498 let revision = table 499 .get("rev") 500 .and_then(toml::Value::as_str) 501 .filter(|value| valid_lower_hex(value, 40)) 502 .ok_or(CommandError::InvalidCargoManifest)?; 503 match &state.revision { 504 Some(expected) if expected != revision => { 505 return Err(CommandError::InvalidCargoManifest); 506 } 507 None => state.revision = Some(revision.to_owned()), 508 _ => {} 509 } 510 state.dependencies += 1; 511 state.host_dependency |= key == Some(HOST_PACKAGE) 512 || table.get("package").and_then(toml::Value::as_str) == Some(HOST_PACKAGE); 513 } 514 for (child_key, child) in table { 515 if (known_lib_dependency || canonical_lib_dependency) && child_key == "git" { 516 continue; 517 } 518 let child_dependency_scope = in_dependencies 519 || matches!( 520 child_key.as_str(), 521 "dependencies" | "dev-dependencies" | "build-dependencies" 522 ); 523 validate_manifest_node( 524 child, 525 Some(child_key), 526 in_patch || child_key == "patch", 527 child_dependency_scope, 528 lib_packages, 529 state, 530 )?; 531 } 532 } 533 toml::Value::Array(values) => { 534 for child in values { 535 validate_manifest_node(child, key, in_patch, in_dependencies, lib_packages, state)?; 536 } 537 } 538 toml::Value::String(_) 539 if in_dependencies 540 && key.is_some_and(|key| { 541 lib_packages.map_or(key == HOST_PACKAGE, |packages| packages.contains(key)) 542 }) => 543 { 544 return Err(CommandError::InvalidCargoManifest); 545 } 546 _ => {} 547 } 548 Ok(()) 549 } 550 551 fn validate_cargo_lock( 552 bytes: &[u8], 553 revision: &str, 554 lib_packages: &BTreeSet<String>, 555 ) -> Result<(), CommandError> { 556 let text = std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidCargoLock)?; 557 let document = 558 toml::from_str::<CargoLockDocument>(text).map_err(|_| CommandError::InvalidCargoLock)?; 559 let expected = format!("git+{LIB_REPOSITORY}?rev={revision}#{revision}"); 560 let mut count = 0_usize; 561 let mut host = false; 562 for package in document.package { 563 if lib_packages.contains(&package.name) { 564 let source = package.source.ok_or(CommandError::InvalidCargoLock)?; 565 if source != expected || package.version != "0.1.0-alpha" { 566 return Err(CommandError::InvalidCargoLock); 567 } 568 count += 1; 569 host |= package.name == HOST_PACKAGE; 570 } 571 } 572 if count != 0 && host { 573 Ok(()) 574 } else { 575 Err(CommandError::InvalidCargoLock) 576 } 577 } 578 579 fn read_nix_material( 580 root: &Path, 581 state: NixMaterialState, 582 ) -> Result<NixMaterialEvidence, CommandError> { 583 match state { 584 NixMaterialState::Absent => { 585 for name in [FLAKE_NIX, FLAKE_LOCK] { 586 match fs::symlink_metadata(root.join(name)) { 587 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 588 _ => return Err(CommandError::InvalidNixMaterial), 589 } 590 } 591 Ok(NixMaterialEvidence::Absent) 592 } 593 NixMaterialState::Deferred => { 594 let expression = read_bounded_regular( 595 &root.join(FLAKE_NIX), 596 MAX_FLAKE_NIX_BYTES, 597 CommandError::InvalidNixMaterial, 598 )?; 599 let lock = read_bounded_regular( 600 &root.join(FLAKE_LOCK), 601 MAX_FLAKE_LOCK_BYTES, 602 CommandError::InvalidFlakeLock, 603 )?; 604 crate::service_source_lock::validate_deferred_nix_lock(&lock) 605 .map_err(|_| CommandError::InvalidFlakeLock)?; 606 let evidence = validate_deferred_nix_material(&expression, &lock) 607 .map_err(|_| CommandError::InvalidNixMaterial)?; 608 Ok(NixMaterialEvidence::Deferred { 609 lib_revision: evidence.lib_revision().to_owned(), 610 flake_lock_sha256: evidence.flake_lock_sha256().to_owned(), 611 }) 612 } 613 } 614 } 615 616 #[cfg(test)] 617 fn validate_flake_lock(bytes: &[u8]) -> Result<String, CommandError> { 618 crate::service_source_lock::validate_deferred_nix_lock(bytes) 619 .map_err(|_| CommandError::InvalidFlakeLock) 620 } 621 622 fn validate_toolchain(root: &Path) -> Result<(), CommandError> { 623 let bytes = read_bounded_regular( 624 &root.join(RUST_TOOLCHAIN), 625 MAX_TOOLCHAIN_BYTES, 626 CommandError::InvalidToolchain, 627 )?; 628 let value = parse_toml(&bytes, CommandError::InvalidToolchain)?; 629 if value 630 .get("toolchain") 631 .and_then(|value| value.get("channel")) 632 .and_then(toml::Value::as_str) 633 == Some(RUST_VERSION) 634 { 635 Ok(()) 636 } else { 637 Err(CommandError::InvalidToolchain) 638 } 639 } 640 641 fn validate_archive(path: &Path, revision: &str) -> Result<ArchiveEvidence, CommandError> { 642 if !path.is_absolute() { 643 return Err(CommandError::InvalidSourceArchive); 644 } 645 let metadata = fs::symlink_metadata(path).map_err(|_| CommandError::InvalidSourceArchive)?; 646 if metadata.file_type().is_symlink() 647 || !metadata.is_file() 648 || metadata.len() == 0 649 || metadata.len() > MAX_ARCHIVE_BYTES 650 { 651 return Err(CommandError::InvalidSourceArchive); 652 } 653 let mut source = fs::File::open(path).map_err(|_| CommandError::InvalidSourceArchive)?; 654 let before = source 655 .metadata() 656 .map_err(|_| CommandError::InvalidSourceArchive)?; 657 let mut stable = NamedTempFile::new().map_err(|_| CommandError::InvalidSourceArchive)?; 658 let mut hasher = Sha256::new(); 659 let mut total = 0_u64; 660 let mut buffer = [0_u8; 65_536]; 661 loop { 662 let read = source 663 .read(&mut buffer) 664 .map_err(|_| CommandError::InvalidSourceArchive)?; 665 if read == 0 { 666 break; 667 } 668 total = total 669 .checked_add(u64::try_from(read).map_err(|_| CommandError::InvalidSourceArchive)?) 670 .filter(|total| *total <= MAX_ARCHIVE_BYTES) 671 .ok_or(CommandError::InvalidSourceArchive)?; 672 hasher.update(&buffer[..read]); 673 stable 674 .write_all(&buffer[..read]) 675 .map_err(|_| CommandError::InvalidSourceArchive)?; 676 } 677 let after = source 678 .metadata() 679 .map_err(|_| CommandError::InvalidSourceArchive)?; 680 if total == 0 || before.len() != total || after.len() != total { 681 return Err(CommandError::InvalidSourceArchive); 682 } 683 stable 684 .flush() 685 .and_then(|()| stable.as_file().sync_all()) 686 .map_err(|_| CommandError::InvalidSourceArchive)?; 687 stable 688 .as_file_mut() 689 .seek(SeekFrom::Start(0)) 690 .map_err(|_| CommandError::InvalidSourceArchive)?; 691 692 let verification = TempDir::new().map_err(|_| CommandError::InvalidSourceArchive)?; 693 git_status(verification.path(), ["init", "--bare", "--quiet"]) 694 .map_err(|_| CommandError::InvalidSourceArchive)?; 695 let status = Command::new("git") 696 .args(["bundle", "verify"]) 697 .arg(stable.path()) 698 .current_dir(verification.path()) 699 .stdin(Stdio::null()) 700 .stdout(Stdio::null()) 701 .stderr(Stdio::null()) 702 .status() 703 .map_err(|_| CommandError::InvalidSourceArchive)?; 704 if !status.success() { 705 return Err(CommandError::InvalidSourceArchive); 706 } 707 let heads = command_stdout( 708 Command::new("git") 709 .args(["bundle", "list-heads"]) 710 .arg(stable.path()), 711 MAX_GIT_OUTPUT_BYTES, 712 ) 713 .map_err(|_| CommandError::InvalidSourceArchive)?; 714 let expected_head = format!("{revision} refs/heads/archive\n"); 715 if heads != expected_head.as_bytes() { 716 return Err(CommandError::InvalidSourceArchive); 717 } 718 let fetch = Command::new("git") 719 .args(["fetch", "--quiet", "--no-tags"]) 720 .arg(stable.path()) 721 .arg(format!("{revision}:refs/heads/archive")) 722 .current_dir(verification.path()) 723 .stdin(Stdio::null()) 724 .stdout(Stdio::null()) 725 .stderr(Stdio::null()) 726 .status() 727 .map_err(|_| CommandError::InvalidSourceArchive)?; 728 if !fetch.success() { 729 return Err(CommandError::InvalidSourceArchive); 730 } 731 let catalog_spec = format!("{revision}:{CATALOG_RELATIVE}"); 732 let catalog = git_stdout( 733 verification.path(), 734 ["show", catalog_spec.as_str()], 735 MAX_CATALOG_BYTES, 736 ) 737 .map_err(|_| CommandError::InvalidSourceArchive)?; 738 if catalog.is_empty() { 739 return Err(CommandError::InvalidSourceArchive); 740 } 741 let package_names = catalog_package_names(&catalog)?; 742 Ok(ArchiveEvidence { 743 revision: revision.to_owned(), 744 archive_sha256: hex::encode(hasher.finalize()), 745 catalog_sha256: sha256(&catalog), 746 package_names, 747 }) 748 } 749 750 fn catalog_package_names(bytes: &[u8]) -> Result<BTreeSet<String>, CommandError> { 751 let value = parse_toml(bytes, CommandError::InvalidSourceArchive)?; 752 if value.get("schema").and_then(toml::Value::as_str) != Some("radroots.workspace.catalog.v2") 753 || value.get("architecture").and_then(toml::Value::as_str) 754 != Some("radroots.crates.release.v2") 755 || value.get("version").and_then(toml::Value::as_str) != Some("0.1.0-alpha") 756 { 757 return Err(CommandError::InvalidSourceArchive); 758 } 759 let packages = value 760 .get("package") 761 .and_then(toml::Value::as_array) 762 .ok_or(CommandError::InvalidSourceArchive)?; 763 let expected_count = value 764 .get("package_count") 765 .and_then(toml::Value::as_integer) 766 .and_then(|count| usize::try_from(count).ok()) 767 .ok_or(CommandError::InvalidSourceArchive)?; 768 if packages.is_empty() || packages.len() != expected_count || packages.len() > MAX_MANIFESTS { 769 return Err(CommandError::InvalidSourceArchive); 770 } 771 let mut names = BTreeSet::new(); 772 for package in packages { 773 let name = package 774 .get("name") 775 .and_then(toml::Value::as_str) 776 .ok_or(CommandError::InvalidSourceArchive)?; 777 if !names.insert(name.to_owned()) { 778 return Err(CommandError::InvalidSourceArchive); 779 } 780 } 781 if names.contains(HOST_PACKAGE) { 782 Ok(names) 783 } else { 784 Err(CommandError::InvalidSourceArchive) 785 } 786 } 787 788 fn atomic_write_lock(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), CommandError> { 789 if let Ok(metadata) = fs::symlink_metadata(path) 790 && (metadata.file_type().is_symlink() || !metadata.is_file()) 791 { 792 return Err(CommandError::WriteFailure); 793 } 794 let mut temporary = NamedTempFile::new_in(root).map_err(|_| CommandError::WriteFailure)?; 795 temporary 796 .write_all(bytes) 797 .and_then(|()| temporary.as_file().sync_all()) 798 .map_err(|_| CommandError::WriteFailure)?; 799 temporary 800 .persist(path) 801 .map_err(|_| CommandError::WriteFailure)?; 802 fs::File::open(root) 803 .and_then(|directory| directory.sync_all()) 804 .map_err(|_| CommandError::WriteFailure) 805 } 806 807 fn read_bounded_regular( 808 path: &Path, 809 maximum: usize, 810 error: CommandError, 811 ) -> Result<Vec<u8>, CommandError> { 812 let metadata = fs::symlink_metadata(path).map_err(|_| error)?; 813 if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum as u64 { 814 return Err(error); 815 } 816 let file = fs::File::open(path).map_err(|_| error)?; 817 let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).map_err(|_| error)?); 818 file.take(maximum as u64 + 1) 819 .read_to_end(&mut bytes) 820 .map_err(|_| error)?; 821 if bytes.len() > maximum { 822 Err(error) 823 } else { 824 Ok(bytes) 825 } 826 } 827 828 fn parse_toml(bytes: &[u8], error: CommandError) -> Result<toml::Value, CommandError> { 829 let text = std::str::from_utf8(bytes).map_err(|_| error)?; 830 toml::from_str(text).map_err(|_| error) 831 } 832 833 fn sha256(bytes: &[u8]) -> String { 834 hex::encode(Sha256::digest(bytes)) 835 } 836 837 fn valid_lower_hex(value: &str, length: usize) -> bool { 838 value.len() == length 839 && value 840 .bytes() 841 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 842 } 843 844 #[cfg(test)] 845 fn valid_nix_sha256(value: &str) -> bool { 846 let Some(encoded) = value.strip_prefix("sha256-") else { 847 return false; 848 }; 849 let bytes = encoded.as_bytes(); 850 bytes.len() == 44 851 && bytes[43] == b'=' 852 && bytes[..43] 853 .iter() 854 .copied() 855 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')) 856 } 857 858 fn git_status<const N: usize>(root: &Path, args: [&str; N]) -> Result<(), ()> { 859 let status = Command::new("git") 860 .args(args) 861 .current_dir(root) 862 .stdin(Stdio::null()) 863 .stdout(Stdio::null()) 864 .stderr(Stdio::null()) 865 .status() 866 .map_err(|_| ())?; 867 if status.success() { Ok(()) } else { Err(()) } 868 } 869 870 fn git_stdout<const N: usize>(root: &Path, args: [&str; N], maximum: usize) -> Result<Vec<u8>, ()> { 871 let mut command = Command::new("git"); 872 command.args(args).current_dir(root); 873 command_stdout(&mut command, maximum) 874 } 875 876 fn command_stdout(command: &mut Command, maximum: usize) -> Result<Vec<u8>, ()> { 877 let mut child = command 878 .stdin(Stdio::null()) 879 .stdout(Stdio::piped()) 880 .stderr(Stdio::null()) 881 .spawn() 882 .map_err(|_| ())?; 883 let mut stdout = child.stdout.take().ok_or(())?; 884 let mut bytes = Vec::new(); 885 if stdout 886 .by_ref() 887 .take(maximum as u64 + 1) 888 .read_to_end(&mut bytes) 889 .is_err() 890 { 891 let _ = child.kill(); 892 let _ = child.wait(); 893 return Err(()); 894 } 895 if bytes.len() > maximum { 896 let _ = child.kill(); 897 let _ = child.wait(); 898 return Err(()); 899 } 900 let status = child.wait().map_err(|_| ())?; 901 if status.success() { Ok(bytes) } else { Err(()) } 902 } 903 904 #[cfg(test)] 905 mod tests { 906 use std::{ 907 error::Error as _, 908 sync::atomic::{AtomicBool, Ordering}, 909 }; 910 911 use super::*; 912 913 struct Reachability { 914 reachable: AtomicBool, 915 } 916 917 impl RevisionReachability for Reachability { 918 fn verify(&self, _revision: &str) -> Result<(), CommandError> { 919 if self.reachable.load(Ordering::SeqCst) { 920 Ok(()) 921 } else { 922 Err(CommandError::UnreachableRevision) 923 } 924 } 925 } 926 927 struct CommitOnVerify { 928 service: PathBuf, 929 } 930 931 impl RevisionReachability for CommitOnVerify { 932 fn verify(&self, _revision: &str) -> Result<(), CommandError> { 933 fs::write(self.service.join("concurrent"), b"changed").expect("concurrent file"); 934 git(&self.service, &["add", "."]); 935 git( 936 &self.service, 937 &["commit", "--quiet", "-m", "concurrent commit"], 938 ); 939 Ok(()) 940 } 941 } 942 943 struct Fixture { 944 root: TempDir, 945 service: PathBuf, 946 archive: PathBuf, 947 revision: String, 948 } 949 950 impl Fixture { 951 fn new() -> Self { 952 let root = TempDir::new().expect("temp root"); 953 let lib = root.path().join("lib"); 954 fs::create_dir(&lib).expect("lib root"); 955 git(&lib, &["init", "--quiet"]); 956 git(&lib, &["config", "user.email", "fixture@example.invalid"]); 957 git(&lib, &["config", "user.name", "fixture"]); 958 let catalog = lib.join(CATALOG_RELATIVE); 959 fs::create_dir_all(catalog.parent().expect("catalog parent")).expect("catalog parent"); 960 fs::write( 961 &catalog, 962 br#"schema_version = 2 963 schema = "radroots.workspace.catalog.v2" 964 architecture = "radroots.crates.release.v2" 965 version = "0.1.0-alpha" 966 package_count = 2 967 968 [[package]] 969 name = "radroots_core" 970 971 [[package]] 972 name = "radroots_service_host" 973 "#, 974 ) 975 .expect("catalog"); 976 git(&lib, &["add", "."]); 977 git(&lib, &["commit", "--quiet", "-m", "fixture"]); 978 git(&lib, &["branch", "-M", "archive"]); 979 let revision = 980 String::from_utf8(git_stdout(&lib, ["rev-parse", "HEAD"], 128).expect("revision")) 981 .expect("UTF-8") 982 .trim() 983 .to_owned(); 984 let archive = root.path().join("lib.bundle"); 985 let status = Command::new("git") 986 .args(["bundle", "create"]) 987 .arg(&archive) 988 .arg("refs/heads/archive") 989 .current_dir(&lib) 990 .status() 991 .expect("bundle"); 992 assert!(status.success()); 993 994 let service = root.path().join("service"); 995 fs::create_dir(&service).expect("service root"); 996 fs::write( 997 service.join(CARGO_MANIFEST), 998 format!( 999 r#"[workspace] 1000 resolver = "3" 1001 1002 [workspace.metadata.radroots.service_source_lock] 1003 service = "fixture_service" 1004 host_feature_profile = "service-host" 1005 nix_material = "deferred" 1006 config_contract_version = 1 1007 state_contract_version = 2 1008 admin_contract_version = 3 1009 status_contract_version = 4 1010 provider_contract_version = 5 1011 1012 [workspace.dependencies] 1013 radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version = "=0.1.0-alpha" }} 1014 "#, 1015 ), 1016 ) 1017 .expect("manifest"); 1018 fs::write( 1019 service.join(CARGO_LOCK), 1020 format!( 1021 "version = 4\n\n[[package]]\nname = \"radroots_service_host\"\nversion = \"0.1.0-alpha\"\nsource = \"git+{LIB_REPOSITORY}?rev={revision}#{revision}\"\n" 1022 ), 1023 ) 1024 .expect("Cargo.lock"); 1025 fs::write( 1026 service.join(FLAKE_NIX), 1027 format!( 1028 "{{\n inputs.lib = {{\n url = \"github:radrootslabs/lib/{revision}\";\n flake = false;\n }};\n outputs = {{ ... }}: {{ }};\n}}\n" 1029 ), 1030 ) 1031 .expect("flake.nix"); 1032 fs::write( 1033 service.join(FLAKE_LOCK), 1034 format!( 1035 r#"{{"nodes":{{"root":{{"inputs":{{"lib":"lib"}}}},"lib":{{"locked":{{"lastModified":1,"narHash":"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","owner":"radrootslabs","repo":"lib","rev":"{revision}","type":"github"}},"original":{{"owner":"radrootslabs","repo":"lib","rev":"{revision}","type":"github"}}}}}},"root":"root","version":7}} 1036 "#, 1037 ), 1038 ) 1039 .expect("flake.lock"); 1040 fs::write( 1041 service.join(RUST_TOOLCHAIN), 1042 "[toolchain]\nchannel = \"1.97.1\"\ncomponents = [\"clippy\", \"rustfmt\"]\n", 1043 ) 1044 .expect("toolchain"); 1045 git(&service, &["init", "--quiet"]); 1046 git( 1047 &service, 1048 &["config", "user.email", "fixture@example.invalid"], 1049 ); 1050 git(&service, &["config", "user.name", "fixture"]); 1051 git(&service, &["add", "."]); 1052 git(&service, &["commit", "--quiet", "-m", "fixture"]); 1053 Self { 1054 root, 1055 service, 1056 archive, 1057 revision, 1058 } 1059 } 1060 1061 fn reachable(&self) -> Reachability { 1062 Reachability { 1063 reachable: AtomicBool::new(true), 1064 } 1065 } 1066 } 1067 1068 fn git(root: &Path, args: &[&str]) { 1069 let status = Command::new("git") 1070 .args(args) 1071 .current_dir(root) 1072 .status() 1073 .expect("git"); 1074 assert!(status.success(), "git {args:?}"); 1075 } 1076 1077 #[test] 1078 fn update_and_check_bind_complete_local_evidence() { 1079 let fixture = Fixture::new(); 1080 let reachability = fixture.reachable(); 1081 run_with( 1082 CommandMode::Write, 1083 &fixture.service, 1084 &fixture.archive, 1085 &reachability, 1086 ) 1087 .expect("write lock"); 1088 run_with( 1089 CommandMode::Check, 1090 &fixture.service, 1091 &fixture.archive, 1092 &reachability, 1093 ) 1094 .expect("check lock"); 1095 1096 let bytes = fs::read(fixture.service.join(LOCK_FILENAME)).expect("lock"); 1097 let text = std::str::from_utf8(&bytes).expect("UTF-8"); 1098 assert!(text.contains("service = \"fixture_service\"")); 1099 assert!(text.contains(&format!("revision = \"{}\"", fixture.revision))); 1100 assert!(text.contains("material = \"deferred\"")); 1101 assert!(text.contains(&format!("lib_revision = \"{}\"", fixture.revision))); 1102 assert!(text.contains("config = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5")); 1103 assert!(fixture.root.path().exists()); 1104 } 1105 1106 #[test] 1107 fn check_rejects_stale_lock_and_update_repairs_it() { 1108 let fixture = Fixture::new(); 1109 let reachability = fixture.reachable(); 1110 run_with( 1111 CommandMode::Write, 1112 &fixture.service, 1113 &fixture.archive, 1114 &reachability, 1115 ) 1116 .expect("write lock"); 1117 let lock = fixture.service.join(LOCK_FILENAME); 1118 let stale = 1119 fs::read_to_string(&lock) 1120 .expect("read lock") 1121 .replacen("config = 1", "config = 9", 1); 1122 fs::write(&lock, stale).expect("stale lock"); 1123 assert_eq!( 1124 run_with( 1125 CommandMode::Check, 1126 &fixture.service, 1127 &fixture.archive, 1128 &reachability, 1129 ), 1130 Err(CommandError::StaleSourceLock) 1131 ); 1132 run_with( 1133 CommandMode::Write, 1134 &fixture.service, 1135 &fixture.archive, 1136 &reachability, 1137 ) 1138 .expect("repair lock"); 1139 } 1140 1141 #[test] 1142 fn dirty_source_and_unreachable_revision_fail_closed() { 1143 let fixture = Fixture::new(); 1144 fs::write(fixture.service.join("untracked-secret"), b"sensitive").expect("dirty file"); 1145 assert_eq!( 1146 run_with( 1147 CommandMode::Write, 1148 &fixture.service, 1149 &fixture.archive, 1150 &fixture.reachable(), 1151 ), 1152 Err(CommandError::DirtyServiceSource) 1153 ); 1154 fs::remove_file(fixture.service.join("untracked-secret")).expect("remove dirty file"); 1155 let unreachable = Reachability { 1156 reachable: AtomicBool::new(false), 1157 }; 1158 assert_eq!( 1159 run_with( 1160 CommandMode::Write, 1161 &fixture.service, 1162 &fixture.archive, 1163 &unreachable, 1164 ), 1165 Err(CommandError::UnreachableRevision) 1166 ); 1167 assert!(!fixture.service.join(LOCK_FILENAME).exists()); 1168 1169 let fixture = Fixture::new(); 1170 let commit_on_verify = CommitOnVerify { 1171 service: fixture.service.clone(), 1172 }; 1173 assert_eq!( 1174 run_with( 1175 CommandMode::Write, 1176 &fixture.service, 1177 &fixture.archive, 1178 &commit_on_verify, 1179 ), 1180 Err(CommandError::DirtyServiceSource) 1181 ); 1182 assert!(!fixture.service.join(LOCK_FILENAME).exists()); 1183 } 1184 1185 #[test] 1186 fn cargo_flake_toolchain_and_archive_drift_are_distinct() { 1187 let cases = [ 1188 ( 1189 CARGO_MANIFEST, 1190 "radroots_service_host", 1191 "other", 1192 CommandError::InvalidCargoManifest, 1193 ), 1194 ( 1195 CARGO_LOCK, 1196 "radroots_service_host", 1197 "other", 1198 CommandError::InvalidCargoLock, 1199 ), 1200 ( 1201 FLAKE_NIX, 1202 "radrootslabs/lib", 1203 "example.invalid/lib", 1204 CommandError::InvalidNixMaterial, 1205 ), 1206 ( 1207 FLAKE_NIX, 1208 "inputs.lib = {", 1209 "other = {", 1210 CommandError::InvalidNixMaterial, 1211 ), 1212 ( 1213 FLAKE_LOCK, 1214 "radrootslabs", 1215 "other", 1216 CommandError::InvalidFlakeLock, 1217 ), 1218 ( 1219 RUST_TOOLCHAIN, 1220 "1.97.1", 1221 "1.97.0", 1222 CommandError::InvalidToolchain, 1223 ), 1224 ]; 1225 for (path, from, to, expected) in cases { 1226 let fixture = Fixture::new(); 1227 let path = fixture.service.join(path); 1228 let mutated = fs::read_to_string(&path) 1229 .expect("read fixture") 1230 .replacen(from, to, 1); 1231 fs::write(&path, mutated).expect("mutate fixture"); 1232 git(&fixture.service, &["add", "."]); 1233 git(&fixture.service, &["commit", "--quiet", "-m", "mutate"]); 1234 assert_eq!( 1235 run_with( 1236 CommandMode::Write, 1237 &fixture.service, 1238 &fixture.archive, 1239 &fixture.reachable(), 1240 ), 1241 Err(expected) 1242 ); 1243 } 1244 1245 let fixture = Fixture::new(); 1246 let other = fixture.root.path().join("other"); 1247 fs::create_dir(&other).expect("other repo"); 1248 git(&other, &["init", "--quiet"]); 1249 git(&other, &["config", "user.email", "fixture@example.invalid"]); 1250 git(&other, &["config", "user.name", "fixture"]); 1251 fs::write(other.join("other"), b"other").expect("other file"); 1252 git(&other, &["add", "."]); 1253 git(&other, &["commit", "--quiet", "-m", "other"]); 1254 git(&other, &["branch", "-M", "archive"]); 1255 let bad_archive = fixture.root.path().join("other.bundle"); 1256 let status = Command::new("git") 1257 .args(["bundle", "create"]) 1258 .arg(&bad_archive) 1259 .arg("refs/heads/archive") 1260 .current_dir(&other) 1261 .status() 1262 .expect("other bundle"); 1263 assert!(status.success()); 1264 assert_eq!( 1265 run_with( 1266 CommandMode::Write, 1267 &fixture.service, 1268 &bad_archive, 1269 &fixture.reachable(), 1270 ), 1271 Err(CommandError::InvalidSourceArchive) 1272 ); 1273 1274 let fixture = Fixture::new(); 1275 let oversized = fixture.root.path().join("oversized.bundle"); 1276 fs::File::create(&oversized) 1277 .and_then(|file| file.set_len(MAX_ARCHIVE_BYTES + 1)) 1278 .expect("sparse oversized archive"); 1279 assert_eq!( 1280 run_with( 1281 CommandMode::Write, 1282 &fixture.service, 1283 &oversized, 1284 &fixture.reachable(), 1285 ), 1286 Err(CommandError::InvalidSourceArchive) 1287 ); 1288 } 1289 1290 #[test] 1291 fn mixed_local_or_unlocked_lib_dependencies_fail_closed() { 1292 let fixture = Fixture::new(); 1293 let manifest = fixture.service.join(CARGO_MANIFEST); 1294 let mut text = fs::read_to_string(&manifest).expect("manifest"); 1295 text.push_str("radroots_core = { path = \"../lib/crates/core\" }\n"); 1296 fs::write(&manifest, text).expect("local Lib dependency"); 1297 git(&fixture.service, &["add", "."]); 1298 git( 1299 &fixture.service, 1300 &["commit", "--quiet", "-m", "local dependency"], 1301 ); 1302 assert_eq!( 1303 run_with( 1304 CommandMode::Write, 1305 &fixture.service, 1306 &fixture.archive, 1307 &fixture.reachable(), 1308 ), 1309 Err(CommandError::InvalidCargoManifest) 1310 ); 1311 1312 let fixture = Fixture::new(); 1313 let cargo_lock = fixture.service.join(CARGO_LOCK); 1314 let mut text = fs::read_to_string(&cargo_lock).expect("Cargo.lock"); 1315 text.push_str("\n[[package]]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\n"); 1316 fs::write(&cargo_lock, text).expect("unlocked Lib package"); 1317 git(&fixture.service, &["add", "."]); 1318 git( 1319 &fixture.service, 1320 &["commit", "--quiet", "-m", "unlocked dependency"], 1321 ); 1322 assert_eq!( 1323 run_with( 1324 CommandMode::Write, 1325 &fixture.service, 1326 &fixture.archive, 1327 &fixture.reachable(), 1328 ), 1329 Err(CommandError::InvalidCargoLock) 1330 ); 1331 1332 let fixture = Fixture::new(); 1333 let manifest = fixture.service.join(CARGO_MANIFEST); 1334 let mut text = fs::read_to_string(&manifest).expect("manifest"); 1335 text.push_str(&format!( 1336 "\n[patch.crates-io]\nradroots_core = {{ git = \"{LIB_REPOSITORY}\", rev = \"{}\", version = \"=0.1.0-alpha\" }}\n", 1337 fixture.revision 1338 )); 1339 fs::write(&manifest, text).expect("Lib patch"); 1340 git(&fixture.service, &["add", "."]); 1341 git(&fixture.service, &["commit", "--quiet", "-m", "Lib patch"]); 1342 assert_eq!( 1343 run_with( 1344 CommandMode::Write, 1345 &fixture.service, 1346 &fixture.archive, 1347 &fixture.reachable(), 1348 ), 1349 Err(CommandError::InvalidCargoManifest) 1350 ); 1351 } 1352 1353 #[test] 1354 fn floating_or_unbounded_nix_input_fails_closed() { 1355 for mutation in [ 1356 ("\"rev\":\"", "\"ref\":\"master\",\"rev\":\""), 1357 ( 1358 "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 1359 "sha256-invalid", 1360 ), 1361 ("\"lastModified\":1,", "\"dir\":\"sub\",\"lastModified\":1,"), 1362 ] { 1363 let fixture = Fixture::new(); 1364 let path = fixture.service.join(FLAKE_LOCK); 1365 let current = fs::read_to_string(&path).expect("flake lock"); 1366 let mutated = if mutation.0 == "\"rev\":\"" { 1367 let second = current.rfind(mutation.0).expect("original revision field"); 1368 format!( 1369 "{}{}{}", 1370 ¤t[..second], 1371 mutation.1, 1372 ¤t[second + mutation.0.len()..] 1373 ) 1374 } else { 1375 current.replacen(mutation.0, mutation.1, 1) 1376 }; 1377 fs::write(&path, mutated).expect("mutate flake lock"); 1378 git(&fixture.service, &["add", "."]); 1379 git(&fixture.service, &["commit", "--quiet", "-m", "mutate"]); 1380 assert_eq!( 1381 run_with( 1382 CommandMode::Write, 1383 &fixture.service, 1384 &fixture.archive, 1385 &fixture.reachable(), 1386 ), 1387 Err(CommandError::InvalidFlakeLock) 1388 ); 1389 } 1390 } 1391 1392 #[test] 1393 fn absent_and_independently_revisioned_deferred_nix_material_are_exact() { 1394 let fixture = Fixture::new(); 1395 let manifest = fixture.service.join(CARGO_MANIFEST); 1396 let source = fs::read_to_string(&manifest).expect("manifest"); 1397 fs::write( 1398 &manifest, 1399 source.replacen( 1400 "nix_material = \"deferred\"", 1401 "nix_material = \"absent\"", 1402 1, 1403 ), 1404 ) 1405 .expect("absent metadata"); 1406 fs::remove_file(fixture.service.join(FLAKE_NIX)).expect("remove flake expression"); 1407 fs::remove_file(fixture.service.join(FLAKE_LOCK)).expect("remove flake lock"); 1408 git(&fixture.service, &["add", "-A"]); 1409 git(&fixture.service, &["commit", "--quiet", "-m", "absent Nix"]); 1410 run_with( 1411 CommandMode::Write, 1412 &fixture.service, 1413 &fixture.archive, 1414 &fixture.reachable(), 1415 ) 1416 .expect("write absent-Nix lock"); 1417 let lock = fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"); 1418 let lock = ServiceSourceLockV2::from_canonical_bytes(&lock).expect("canonical lock"); 1419 assert_eq!(lock.nix_material_state(), NixMaterialState::Absent); 1420 assert_eq!(lock.nix_lib_revision(), None); 1421 assert_eq!(lock.flake_lock_sha256(), None); 1422 1423 let fixture = Fixture::new(); 1424 let deferred_revision = "b".repeat(40); 1425 for name in [FLAKE_NIX, FLAKE_LOCK] { 1426 let path = fixture.service.join(name); 1427 let current = fs::read_to_string(&path).expect("Nix source"); 1428 fs::write( 1429 &path, 1430 current.replace(&fixture.revision, &deferred_revision), 1431 ) 1432 .expect("deferred revision"); 1433 } 1434 git(&fixture.service, &["add", "."]); 1435 git( 1436 &fixture.service, 1437 &["commit", "--quiet", "-m", "defer Nix revision"], 1438 ); 1439 run_with( 1440 CommandMode::Write, 1441 &fixture.service, 1442 &fixture.archive, 1443 &fixture.reachable(), 1444 ) 1445 .expect("write independently revisioned lock"); 1446 let lock = fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"); 1447 let lock = ServiceSourceLockV2::from_canonical_bytes(&lock).expect("canonical lock"); 1448 assert_eq!(lock.revision(), fixture.revision); 1449 assert_eq!(lock.nix_material_state(), NixMaterialState::Deferred); 1450 assert_eq!(lock.nix_lib_revision(), Some(deferred_revision.as_str())); 1451 } 1452 1453 #[test] 1454 fn predecessor_source_lock_is_rejected_instead_of_forming_dual_authority() { 1455 let fixture = Fixture::new(); 1456 fs::write( 1457 fixture.service.join(PREDECESSOR_LOCK_FILENAME), 1458 b"historical lock", 1459 ) 1460 .expect("predecessor lock"); 1461 git(&fixture.service, &["add", "."]); 1462 git( 1463 &fixture.service, 1464 &["commit", "--quiet", "-m", "retain predecessor lock"], 1465 ); 1466 assert_eq!( 1467 run_with( 1468 CommandMode::Write, 1469 &fixture.service, 1470 &fixture.archive, 1471 &fixture.reachable(), 1472 ), 1473 Err(CommandError::InvalidSourceLock) 1474 ); 1475 } 1476 1477 #[test] 1478 fn partial_or_mismatched_nix_material_fails_closed() { 1479 for removed in [FLAKE_NIX, FLAKE_LOCK] { 1480 let fixture = Fixture::new(); 1481 fs::remove_file(fixture.service.join(removed)).expect("remove one Nix input"); 1482 git(&fixture.service, &["add", "-A"]); 1483 git( 1484 &fixture.service, 1485 &["commit", "--quiet", "-m", "partial Nix"], 1486 ); 1487 assert!(matches!( 1488 run_with( 1489 CommandMode::Write, 1490 &fixture.service, 1491 &fixture.archive, 1492 &fixture.reachable(), 1493 ), 1494 Err(CommandError::InvalidNixMaterial | CommandError::InvalidFlakeLock) 1495 )); 1496 } 1497 1498 let fixture = Fixture::new(); 1499 let expression = fixture.service.join(FLAKE_NIX); 1500 let source = fs::read_to_string(&expression).expect("flake expression"); 1501 fs::write( 1502 &expression, 1503 source.replace(&fixture.revision, &"b".repeat(40)), 1504 ) 1505 .expect("mismatch expression"); 1506 git(&fixture.service, &["add", "."]); 1507 git( 1508 &fixture.service, 1509 &["commit", "--quiet", "-m", "mismatch Nix"], 1510 ); 1511 assert_eq!( 1512 run_with( 1513 CommandMode::Write, 1514 &fixture.service, 1515 &fixture.archive, 1516 &fixture.reachable(), 1517 ), 1518 Err(CommandError::InvalidNixMaterial) 1519 ); 1520 } 1521 1522 #[test] 1523 fn flake_lock_rejects_each_independent_fixed_field_drift() { 1524 let fixture = Fixture::new(); 1525 let bytes = fs::read(fixture.service.join(FLAKE_LOCK)).expect("flake lock"); 1526 let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("flake json"); 1527 for (pointer, replacement) in [ 1528 ("/version", serde_json::json!(6)), 1529 ("/nodes/root/inputs/lib", serde_json::json!("other")), 1530 ("/nodes/lib/locked/type", serde_json::json!("git")), 1531 ("/nodes/lib/locked/owner", serde_json::json!("other")), 1532 ("/nodes/lib/locked/repo", serde_json::json!("other")), 1533 ("/nodes/lib/locked/rev", serde_json::json!("other")), 1534 ("/nodes/lib/locked/lastModified", serde_json::json!("1")), 1535 ( 1536 "/nodes/lib/locked/narHash", 1537 serde_json::json!("sha256-invalid"), 1538 ), 1539 ("/nodes/lib/original/type", serde_json::json!("git")), 1540 ("/nodes/lib/original/owner", serde_json::json!("other")), 1541 ("/nodes/lib/original/repo", serde_json::json!("other")), 1542 ("/nodes/lib/original/rev", serde_json::json!("other")), 1543 ] { 1544 let mut drifted = canonical.clone(); 1545 *drifted.pointer_mut(pointer).expect("governed field") = replacement; 1546 assert_eq!( 1547 validate_flake_lock(&serde_json::to_vec(&drifted).expect("flake json")), 1548 Err(CommandError::InvalidFlakeLock), 1549 "accepted drift at {pointer}" 1550 ); 1551 } 1552 1553 for (section, field) in [ 1554 ("locked", "extra"), 1555 ("original", "extra"), 1556 ("original", "ref"), 1557 ] { 1558 let mut drifted = canonical.clone(); 1559 drifted["nodes"]["lib"][section] 1560 .as_object_mut() 1561 .expect("flake section") 1562 .insert(field.into(), serde_json::json!("unexpected")); 1563 assert_eq!( 1564 validate_flake_lock(&serde_json::to_vec(&drifted).expect("flake json")), 1565 Err(CommandError::InvalidFlakeLock), 1566 "accepted {section}.{field}" 1567 ); 1568 } 1569 1570 let mut extra_node_field = canonical.clone(); 1571 extra_node_field["nodes"]["lib"]["extra"] = serde_json::json!(true); 1572 assert_eq!( 1573 validate_flake_lock(&serde_json::to_vec(&extra_node_field).expect("flake json")), 1574 Err(CommandError::InvalidFlakeLock) 1575 ); 1576 1577 let mut duplicate = canonical.clone(); 1578 duplicate["nodes"]["lib2"] = duplicate["nodes"]["lib"].clone(); 1579 assert_eq!( 1580 validate_flake_lock(&serde_json::to_vec(&duplicate).expect("flake json")), 1581 Err(CommandError::InvalidFlakeLock) 1582 ); 1583 1584 let aliased = String::from_utf8(bytes.clone()) 1585 .expect("flake UTF-8") 1586 .replacen("\"lib\":\"lib\"", "\"other\":\"lib\"", 1); 1587 assert_eq!( 1588 validate_flake_lock(aliased.as_bytes()), 1589 Err(CommandError::InvalidFlakeLock) 1590 ); 1591 let duplicated = String::from_utf8(bytes).expect("flake UTF-8").replacen( 1592 "\"version\":7", 1593 "\"version\":7,\"version\":7", 1594 1, 1595 ); 1596 assert_eq!( 1597 validate_flake_lock(duplicated.as_bytes()), 1598 Err(CommandError::InvalidFlakeLock) 1599 ); 1600 } 1601 1602 #[test] 1603 fn service_metadata_and_catalog_reject_independent_field_drift() { 1604 let fixture = Fixture::new(); 1605 let bytes = fs::read(fixture.service.join(CARGO_MANIFEST)).expect("manifest"); 1606 let canonical = parse_toml(&bytes, CommandError::InvalidCargoManifest).expect("manifest"); 1607 for (field, replacement) in [ 1608 ("service", toml::Value::Integer(1)), 1609 ("host_feature_profile", toml::Value::String("other".into())), 1610 ("nix_material", toml::Value::String("other".into())), 1611 ("config_contract_version", toml::Value::Integer(0)), 1612 ("state_contract_version", toml::Value::Integer(0)), 1613 ("admin_contract_version", toml::Value::Integer(0)), 1614 ("status_contract_version", toml::Value::Integer(0)), 1615 ("provider_contract_version", toml::Value::Integer(0)), 1616 ] { 1617 let mut drifted = canonical.clone(); 1618 drifted["workspace"]["metadata"]["radroots"]["service_source_lock"][field] = 1619 replacement; 1620 assert_eq!( 1621 parse_service_metadata(&drifted), 1622 Err(CommandError::InvalidServiceMetadata), 1623 "accepted metadata drift at {field}" 1624 ); 1625 } 1626 let mut extra = canonical; 1627 extra["workspace"]["metadata"]["radroots"]["service_source_lock"] 1628 .as_table_mut() 1629 .expect("metadata table") 1630 .insert("extra".into(), toml::Value::Integer(1)); 1631 assert_eq!( 1632 parse_service_metadata(&extra), 1633 Err(CommandError::InvalidServiceMetadata) 1634 ); 1635 1636 let catalog = br#"schema = "radroots.workspace.catalog.v2" 1637 architecture = "radroots.crates.release.v2" 1638 version = "0.1.0-alpha" 1639 package_count = 2 1640 1641 [[package]] 1642 name = "radroots_core" 1643 1644 [[package]] 1645 name = "radroots_service_host" 1646 "#; 1647 let canonical = parse_toml(catalog, CommandError::InvalidSourceArchive).expect("catalog"); 1648 for (field, replacement) in [ 1649 ("schema", toml::Value::String("other".into())), 1650 ("architecture", toml::Value::String("other".into())), 1651 ("version", toml::Value::String("other".into())), 1652 ("package_count", toml::Value::Integer(1)), 1653 ] { 1654 let mut drifted = canonical.clone(); 1655 drifted[field] = replacement; 1656 assert_eq!( 1657 catalog_package_names(toml::to_string(&drifted).expect("catalog").as_bytes()), 1658 Err(CommandError::InvalidSourceArchive), 1659 "accepted catalog drift at {field}" 1660 ); 1661 } 1662 let mut duplicate = canonical.clone(); 1663 duplicate["package"][1]["name"] = toml::Value::String("radroots_core".into()); 1664 assert_eq!( 1665 catalog_package_names(toml::to_string(&duplicate).expect("catalog").as_bytes()), 1666 Err(CommandError::InvalidSourceArchive) 1667 ); 1668 let mut missing_host = canonical; 1669 missing_host["package"][1]["name"] = toml::Value::String("radroots_other".into()); 1670 assert_eq!( 1671 catalog_package_names(toml::to_string(&missing_host).expect("catalog").as_bytes()), 1672 Err(CommandError::InvalidSourceArchive) 1673 ); 1674 } 1675 1676 #[test] 1677 fn manifest_and_cargo_lock_reject_each_independent_dependency_drift() { 1678 let revision = "a".repeat(40); 1679 let canonical = format!( 1680 "[dependencies]\nradroots_service_host = {{ git = \"{LIB_REPOSITORY}\", rev = \"{revision}\", version = \"{LIB_VERSION_REQUIREMENT}\" }}\n" 1681 ); 1682 for (from, to) in [ 1683 (LIB_REPOSITORY, "https://example.invalid/lib"), 1684 (LIB_VERSION_REQUIREMENT, "=0.2.0"), 1685 (revision.as_str(), "invalid"), 1686 ] { 1687 let value = 1688 toml::from_str::<toml::Value>(&canonical.replacen(from, to, 1)).expect("manifest"); 1689 assert_eq!( 1690 validate_manifest_node( 1691 &value, 1692 None, 1693 false, 1694 false, 1695 None, 1696 &mut ManifestState::default() 1697 ), 1698 Err(CommandError::InvalidCargoManifest) 1699 ); 1700 } 1701 for forbidden in ["branch", "tag", "path"] { 1702 let mutated = canonical.replacen( 1703 "version = \"=0.1.0-alpha\"", 1704 &format!("version = \"=0.1.0-alpha\", {forbidden} = \"forbidden\""), 1705 1, 1706 ); 1707 let value = toml::from_str::<toml::Value>(&mutated).expect("manifest"); 1708 assert_eq!( 1709 validate_manifest_node( 1710 &value, 1711 None, 1712 false, 1713 false, 1714 None, 1715 &mut ManifestState::default() 1716 ), 1717 Err(CommandError::InvalidCargoManifest), 1718 "accepted {forbidden}" 1719 ); 1720 } 1721 let value = toml::from_str::<toml::Value>(&canonical).expect("manifest"); 1722 assert_eq!( 1723 validate_manifest_node( 1724 &value, 1725 None, 1726 true, 1727 false, 1728 None, 1729 &mut ManifestState::default() 1730 ), 1731 Err(CommandError::InvalidCargoManifest) 1732 ); 1733 let mut state = ManifestState { 1734 revision: Some("b".repeat(40)), 1735 ..ManifestState::default() 1736 }; 1737 assert_eq!( 1738 validate_manifest_node(&value, None, false, false, None, &mut state), 1739 Err(CommandError::InvalidCargoManifest) 1740 ); 1741 1742 let packages = BTreeSet::from([HOST_PACKAGE.to_owned(), "radroots_core".to_owned()]); 1743 let expected = format!("git+{LIB_REPOSITORY}?rev={revision}#{revision}"); 1744 for document in [ 1745 format!( 1746 "version = 4\n\n[[package]]\nname = \"{HOST_PACKAGE}\"\nversion = \"0.1.0-alpha\"\n" 1747 ), 1748 format!( 1749 "version = 4\n\n[[package]]\nname = \"{HOST_PACKAGE}\"\nversion = \"0.1.0-alpha\"\nsource = \"other\"\n" 1750 ), 1751 format!( 1752 "version = 4\n\n[[package]]\nname = \"{HOST_PACKAGE}\"\nversion = \"0.2.0\"\nsource = \"{expected}\"\n" 1753 ), 1754 format!( 1755 "version = 4\n\n[[package]]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\nsource = \"{expected}\"\n" 1756 ), 1757 "version = 4\n".to_owned(), 1758 ] { 1759 assert_eq!( 1760 validate_cargo_lock(document.as_bytes(), &revision, &packages), 1761 Err(CommandError::InvalidCargoLock) 1762 ); 1763 } 1764 } 1765 1766 #[test] 1767 fn archive_and_manifest_filesystem_admission_is_fail_closed() { 1768 let root = TempDir::new().expect("filesystem fixture"); 1769 let empty = root.path().join("empty"); 1770 fs::write(&empty, b"").expect("empty file"); 1771 assert!(matches!( 1772 validate_archive(Path::new("relative"), &"a".repeat(40)), 1773 Err(CommandError::InvalidSourceArchive) 1774 )); 1775 assert!(matches!( 1776 validate_archive(root.path(), &"a".repeat(40)), 1777 Err(CommandError::InvalidSourceArchive) 1778 )); 1779 assert!(matches!( 1780 validate_archive(&empty, &"a".repeat(40)), 1781 Err(CommandError::InvalidSourceArchive) 1782 )); 1783 1784 let no_manifest = root.path().join("no-manifest"); 1785 fs::create_dir(&no_manifest).expect("empty tree"); 1786 assert_eq!( 1787 validate_cargo_manifests(&no_manifest, None), 1788 Err(CommandError::InvalidCargoManifest) 1789 ); 1790 let no_dependency = root.path().join("no-dependency"); 1791 fs::create_dir(&no_dependency).expect("manifest tree"); 1792 fs::write( 1793 no_dependency.join(CARGO_MANIFEST), 1794 "[workspace]\nresolver = \"3\"\n", 1795 ) 1796 .expect("manifest"); 1797 assert_eq!( 1798 validate_cargo_manifests(&no_dependency, None), 1799 Err(CommandError::InvalidCargoManifest) 1800 ); 1801 1802 #[cfg(unix)] 1803 { 1804 let archive_link = root.path().join("archive-link"); 1805 std::os::unix::fs::symlink(&empty, &archive_link).expect("archive symlink"); 1806 assert!(matches!( 1807 validate_archive(&archive_link, &"a".repeat(40)), 1808 Err(CommandError::InvalidSourceArchive) 1809 )); 1810 let manifest_link_root = root.path().join("manifest-link"); 1811 fs::create_dir(&manifest_link_root).expect("manifest symlink tree"); 1812 std::os::unix::fs::symlink(&empty, manifest_link_root.join(CARGO_MANIFEST)) 1813 .expect("manifest symlink"); 1814 assert_eq!( 1815 validate_cargo_manifests(&manifest_link_root, None), 1816 Err(CommandError::InvalidCargoManifest) 1817 ); 1818 } 1819 } 1820 1821 #[test] 1822 fn service_manifest_discovery_skips_swiftpm_build_output_only() { 1823 let fixture = Fixture::new(); 1824 let swiftpm_checkout = fixture.service.join(".build/checkouts/dependency"); 1825 fs::create_dir_all(&swiftpm_checkout).expect("SwiftPM checkout directory"); 1826 fs::write(swiftpm_checkout.join(CARGO_MANIFEST), "not valid TOML") 1827 .expect("SwiftPM build manifest"); 1828 assert_eq!( 1829 validate_cargo_manifests(&fixture.service, None), 1830 Ok(fixture.revision) 1831 ); 1832 1833 let source_lookalike = fixture.service.join(".builder"); 1834 fs::create_dir(&source_lookalike).expect("source lookalike directory"); 1835 fs::write(source_lookalike.join(CARGO_MANIFEST), "not valid TOML") 1836 .expect("source lookalike manifest"); 1837 assert_eq!( 1838 validate_cargo_manifests(&fixture.service, None), 1839 Err(CommandError::InvalidCargoManifest) 1840 ); 1841 } 1842 1843 #[test] 1844 fn source_lock_output_rejects_symlink_replacement() { 1845 let fixture = Fixture::new(); 1846 let target = fixture.root.path().join("foreign"); 1847 fs::write(&target, b"foreign").expect("foreign"); 1848 #[cfg(unix)] 1849 std::os::unix::fs::symlink(&target, fixture.service.join(LOCK_FILENAME)).expect("symlink"); 1850 #[cfg(unix)] 1851 assert_eq!( 1852 run_with( 1853 CommandMode::Write, 1854 &fixture.service, 1855 &fixture.archive, 1856 &fixture.reachable(), 1857 ), 1858 Err(CommandError::WriteFailure) 1859 ); 1860 #[cfg(unix)] 1861 assert_eq!(fs::read(&target).expect("foreign remains"), b"foreign"); 1862 } 1863 1864 #[test] 1865 fn low_level_source_lock_admission_branches_are_qualified() { 1866 let root = TempDir::new().expect("low-level source-lock fixture"); 1867 let regular = root.path().join("regular"); 1868 fs::write(®ular, b"same").expect("regular file"); 1869 assert_eq!( 1870 read_bounded_regular(®ular, 4, CommandError::InvalidSourceLock) 1871 .expect("bounded regular file"), 1872 b"same" 1873 ); 1874 assert_eq!( 1875 validate_service_root(Path::new("relative")), 1876 Err(CommandError::InvalidServiceRoot) 1877 ); 1878 assert_eq!( 1879 validate_service_root(®ular), 1880 Err(CommandError::InvalidServiceRoot) 1881 ); 1882 1883 let directory = root.path().join("directory"); 1884 fs::create_dir(&directory).expect("directory fixture"); 1885 assert_eq!( 1886 read_bounded_regular(&directory, 4, CommandError::InvalidSourceLock), 1887 Err(CommandError::InvalidSourceLock) 1888 ); 1889 let oversized = root.path().join("oversized"); 1890 fs::write(&oversized, b"12345").expect("oversized file"); 1891 assert_eq!( 1892 read_bounded_regular(&oversized, 4, CommandError::InvalidSourceLock), 1893 Err(CommandError::InvalidSourceLock) 1894 ); 1895 1896 #[cfg(unix)] 1897 { 1898 let symlink = root.path().join("regular-link"); 1899 std::os::unix::fs::symlink(®ular, &symlink).expect("regular symlink"); 1900 assert_eq!( 1901 validate_service_root(&symlink), 1902 Err(CommandError::InvalidServiceRoot) 1903 ); 1904 assert_eq!( 1905 read_bounded_regular(&symlink, 4, CommandError::InvalidSourceLock), 1906 Err(CommandError::InvalidSourceLock) 1907 ); 1908 } 1909 1910 let repository = root.path().join("repository"); 1911 fs::create_dir(&repository).expect("repository"); 1912 git(&repository, &["init", "--quiet"]); 1913 let child = repository.join("child"); 1914 fs::create_dir(&child).expect("nested directory"); 1915 assert_eq!( 1916 validate_service_root(&child), 1917 Err(CommandError::InvalidServiceRoot) 1918 ); 1919 1920 let empty_catalog = br#"schema = "radroots.workspace.catalog.v2" 1921 architecture = "radroots.crates.release.v2" 1922 version = "0.1.0-alpha" 1923 package_count = 0 1924 package = [] 1925 "#; 1926 assert_eq!( 1927 catalog_package_names(empty_catalog), 1928 Err(CommandError::InvalidSourceArchive) 1929 ); 1930 1931 let dependency = toml::Value::String("=0.1.0-alpha".into()); 1932 let packages = BTreeSet::from([HOST_PACKAGE.to_owned()]); 1933 assert_eq!( 1934 validate_manifest_node( 1935 &dependency, 1936 Some(HOST_PACKAGE), 1937 false, 1938 true, 1939 Some(&packages), 1940 &mut ManifestState::default(), 1941 ), 1942 Err(CommandError::InvalidCargoManifest) 1943 ); 1944 1945 assert_eq!( 1946 validate_flake_lock(br#"{"nodes":{"root":{"inputs":{}}},"root":"root","version":7}"#,), 1947 Err(CommandError::InvalidFlakeLock) 1948 ); 1949 assert_eq!( 1950 validate_flake_lock( 1951 br#"{"nodes":{"root":{"inputs":{"lib":"lib"}},"lib":{"original":{"owner":"radrootslabs","repo":"lib"}}},"root":"root","version":7}"#, 1952 ), 1953 Err(CommandError::InvalidFlakeLock) 1954 ); 1955 1956 assert!(!valid_nix_sha256("not-a-digest")); 1957 assert!(!valid_nix_sha256( 1958 "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 1959 )); 1960 assert!(!valid_nix_sha256( 1961 "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA?=" 1962 )); 1963 1964 let mut oversized_stdout = Command::new("sh"); 1965 oversized_stdout.args(["-c", "printf 12345"]); 1966 assert_eq!(command_stdout(&mut oversized_stdout, 4), Err(())); 1967 let mut failed_stdout = Command::new("sh"); 1968 failed_stdout.args(["-c", "exit 7"]); 1969 assert_eq!(command_stdout(&mut failed_stdout, 4), Err(())); 1970 assert_eq!( 1971 git_status(root.path(), ["rev-parse", "--verify", "refs/heads/missing"]), 1972 Err(()) 1973 ); 1974 } 1975 1976 #[test] 1977 fn operational_diagnostics_are_fixed_and_source_free() { 1978 let errors = [ 1979 CommandError::InvalidServiceRoot, 1980 CommandError::DirtyServiceSource, 1981 CommandError::InvalidServiceMetadata, 1982 CommandError::InvalidCargoManifest, 1983 CommandError::InvalidCargoLock, 1984 CommandError::InvalidNixMaterial, 1985 CommandError::InvalidFlakeLock, 1986 CommandError::InvalidToolchain, 1987 CommandError::InvalidSourceArchive, 1988 CommandError::UnreachableRevision, 1989 CommandError::InvalidSourceLock, 1990 CommandError::StaleSourceLock, 1991 CommandError::WriteFailure, 1992 ]; 1993 for error in errors { 1994 assert!(error.source().is_none()); 1995 let rendered = format!("{error:?} {error}"); 1996 assert!(!rendered.contains("fixture_service")); 1997 assert!(!rendered.contains("radrootslabs")); 1998 assert!(!rendered.contains("/tmp")); 1999 } 2000 } 2001 }