main.rs (67426B)
1 #![forbid(unsafe_code)] 2 3 mod rshr_202_step_301_gate; 4 mod rshr_202_step_301_platform; 5 6 use std::{ 7 collections::{BTreeMap, BTreeSet}, 8 env, fmt, fs, 9 io::{Read as _, Write as _}, 10 path::{Path, PathBuf}, 11 process::{Command, Stdio}, 12 }; 13 14 use flate2::{Compression, GzBuilder}; 15 use serde::{Deserialize, Serialize}; 16 use sha2::{Digest as _, Sha256}; 17 use tar::{Builder as TarBuilder, Header as TarHeader}; 18 use tempfile::{NamedTempFile, TempDir}; 19 20 const SERVICE: &str = "rhi"; 21 const VERSION: &str = "0.1.0"; 22 const REPOSITORY: &str = "https://github.com/radrootslabs/rhi"; 23 const RUST_VERSION: &str = "1.97.1"; 24 const HOST_FEATURE_PROFILE: &str = "service-host"; 25 const RADROOTS_DEPENDENCY_COUNT: usize = 12; 26 const SOURCE_LOCK: &str = "radroots.service.source-lock.v3.toml"; 27 const CONFIG_EXAMPLE: &str = "contracts/services_hardening/config.v1.example.toml"; 28 const CONFIG_SCHEMA: &str = "contracts/services_hardening/config.v1.schema.json"; 29 const SYSTEMD_UNIT: &str = "packaging/systemd/rhi@.service"; 30 const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]; 31 const OUTPUT_NAMES: [&str; 12] = [ 32 "LICENSE", 33 "SHA256SUMS", 34 "THIRD-PARTY-NOTICES.txt", 35 "artifact-manifest.v1.json", 36 "binary.tar.gz", 37 "config.example.toml", 38 "config.schema.json", 39 "provenance-input.v1.json", 40 SOURCE_LOCK, 41 "sbom.cdx.json", 42 "service-source.tar.gz", 43 "systemd.service", 44 ]; 45 const MAX_TEXT_BYTES: u64 = 1_048_576; 46 const MAX_DOCUMENT_BYTES: u64 = 16_777_216; 47 const MAX_METADATA_BYTES: u64 = 33_554_432; 48 const MAX_BINARY_BYTES: u64 = 536_870_912; 49 const MAX_SOURCE_ARCHIVE_BYTES: u64 = 1_073_741_824; 50 const MAX_TRACKED_FILES: usize = 4_096; 51 const MAX_PACKAGES: usize = 8_192; 52 const COPY_BUFFER_BYTES: usize = 65_536; 53 const SECRET_PATTERN_PARTS: [(&[u8], &[u8]); 7] = [ 54 (b"-----BEGIN PRIVATE ", b"KEY-----"), 55 (b"-----BEGIN RSA PRIVATE ", b"KEY-----"), 56 (b"-----BEGIN EC PRIVATE ", b"KEY-----"), 57 (b"-----BEGIN OPENSSH PRIVATE ", b"KEY-----"), 58 (b"github_", b"pat_"), 59 (b"gh", b"p_"), 60 (b"xo", b"xb-"), 61 ]; 62 63 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 64 enum Mode { 65 Check, 66 Write, 67 } 68 69 #[derive(Debug)] 70 struct NativeReleaseArgs { 71 mode: Mode, 72 target: String, 73 binary: PathBuf, 74 output: PathBuf, 75 source_date_epoch: u32, 76 } 77 78 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 79 enum ReleaseError { 80 InvalidArguments, 81 InvalidSource, 82 DirtySource, 83 InvalidBinary, 84 InvalidOutput, 85 InvalidMetadata, 86 InvalidSourceLock, 87 ProtectedMaterial, 88 StaleOutput, 89 Generation, 90 } 91 92 impl ReleaseError { 93 const fn code(self) -> &'static str { 94 match self { 95 Self::InvalidArguments => "invalid_arguments", 96 Self::InvalidSource => "invalid_source", 97 Self::DirtySource => "dirty_source", 98 Self::InvalidBinary => "invalid_binary", 99 Self::InvalidOutput => "invalid_output", 100 Self::InvalidMetadata => "invalid_metadata", 101 Self::InvalidSourceLock => "invalid_source_lock", 102 Self::ProtectedMaterial => "protected_material_detected", 103 Self::StaleOutput => "stale_output", 104 Self::Generation => "generation_failure", 105 } 106 } 107 } 108 109 impl fmt::Display for ReleaseError { 110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 111 formatter.write_str(match self { 112 Self::InvalidArguments => "native release arguments are invalid", 113 Self::InvalidSource => "native release source is invalid", 114 Self::DirtySource => "native release source is not an exact clean revision", 115 Self::InvalidBinary => "native release binary is invalid", 116 Self::InvalidOutput => "native release output is invalid", 117 Self::InvalidMetadata => "native release metadata is invalid", 118 Self::InvalidSourceLock => "native release source lock is invalid", 119 Self::ProtectedMaterial => "native release input contains protected material", 120 Self::StaleOutput => "native release artifact set is absent or stale", 121 Self::Generation => "native release artifacts could not be generated", 122 }) 123 } 124 } 125 126 impl std::error::Error for ReleaseError {} 127 128 #[derive(Clone, Debug, Deserialize)] 129 struct CargoMetadata { 130 packages: Vec<CargoPackage>, 131 workspace_members: Vec<String>, 132 resolve: Option<CargoResolve>, 133 } 134 135 #[derive(Clone, Debug, Deserialize)] 136 struct CargoPackage { 137 id: String, 138 name: String, 139 version: String, 140 source: Option<String>, 141 checksum: Option<String>, 142 license: Option<String>, 143 } 144 145 #[derive(Clone, Debug, Deserialize)] 146 struct CargoResolve { 147 nodes: Vec<CargoNode>, 148 } 149 150 #[derive(Clone, Debug, Deserialize)] 151 struct CargoNode { 152 id: String, 153 dependencies: Vec<String>, 154 } 155 156 #[derive(Clone, Debug, Deserialize)] 157 #[serde(deny_unknown_fields)] 158 struct SourceLock { 159 schema: String, 160 contract_version: u32, 161 service: String, 162 repository: String, 163 revision: String, 164 architecture: String, 165 workspace_catalog_sha256: String, 166 version: String, 167 source_archive_sha256: String, 168 source_archive_contract: SourceArchiveContract, 169 cargo_lock_sha256: String, 170 rust_version: String, 171 host_feature_profile: String, 172 nix: NixEvidence, 173 artifact_contract: ArtifactContract, 174 sqlite: SqliteContract, 175 contract_versions: ContractVersions, 176 } 177 178 #[derive(Clone, Debug, Deserialize)] 179 #[serde(deny_unknown_fields)] 180 struct NixEvidence { 181 material: String, 182 lib_revision: String, 183 public_input_lock: PublicInputLock, 184 parent_result: ParentResult, 185 supported_systems: Vec<String>, 186 } 187 188 #[derive(Clone, Debug, Deserialize)] 189 #[serde(deny_unknown_fields)] 190 struct PublicInputLock { 191 path: String, 192 sha256: String, 193 binding: String, 194 mutable_reference: String, 195 lib_input: String, 196 } 197 198 #[derive(Clone, Debug, Deserialize)] 199 #[serde(deny_unknown_fields)] 200 struct ParentResult { 201 embedded_in_public_input_lock: bool, 202 embedded_in_source_lock: bool, 203 storage: String, 204 } 205 206 #[derive(Clone, Debug, Deserialize)] 207 #[serde(deny_unknown_fields)] 208 struct SourceArchiveContract { 209 binding: String, 210 format: String, 211 compression: String, 212 compression_timestamp: String, 213 entry_order: String, 214 path_prefix: String, 215 file_mode: String, 216 uid: u32, 217 gid: u32, 218 uname: String, 219 gname: String, 220 mtime: String, 221 pax_headers: String, 222 directory_entries: String, 223 symlinks: String, 224 hardlinks: String, 225 submodules: String, 226 trailer: String, 227 } 228 229 #[derive(Clone, Debug, Deserialize)] 230 #[serde(deny_unknown_fields)] 231 struct ArtifactContract { 232 path: String, 233 sha256: String, 234 binding: String, 235 } 236 237 #[derive(Clone, Debug, Deserialize)] 238 #[serde(deny_unknown_fields)] 239 struct SqliteContract { 240 high_level_authority: String, 241 second_pool_connection_query_transaction_migration_authority: String, 242 incremental_backup_adapter: String, 243 native_linkage_count: u32, 244 } 245 246 #[derive(Clone, Debug, Deserialize, Serialize)] 247 #[serde(deny_unknown_fields)] 248 struct ContractVersions { 249 config: u32, 250 state: u32, 251 admin: u32, 252 status: u32, 253 provider: u32, 254 } 255 256 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 257 struct ArtifactRecord { 258 path: String, 259 byte_length: u64, 260 sha256: String, 261 } 262 263 #[derive(Debug, Serialize)] 264 struct ArtifactManifest { 265 schema: &'static str, 266 contract_version: u32, 267 service: &'static str, 268 version: &'static str, 269 target: String, 270 source_date_epoch: u32, 271 service_repository: &'static str, 272 service_revision: String, 273 lib_repository: String, 274 lib_revision: String, 275 rust_version: &'static str, 276 host_feature_profile: &'static str, 277 contract_versions: ContractVersions, 278 protected_material_included: bool, 279 nix_qualified: bool, 280 oci_included: bool, 281 artifacts: Vec<ArtifactRecord>, 282 } 283 284 #[derive(Debug, Serialize)] 285 struct ProvenanceInput { 286 schema: &'static str, 287 contract_version: u32, 288 predicate_type: &'static str, 289 build_type: &'static str, 290 builder_id: &'static str, 291 service: &'static str, 292 version: &'static str, 293 target: String, 294 source_date_epoch: u32, 295 service_repository: &'static str, 296 service_revision: String, 297 lib_repository: String, 298 lib_revision: String, 299 source_lock_sha256: String, 300 manifest_sha256: String, 301 subjects: Vec<ArtifactRecord>, 302 signing_required: bool, 303 signed: bool, 304 } 305 306 #[derive(Debug, Serialize)] 307 struct CycloneDxBom { 308 #[serde(rename = "bomFormat")] 309 bom_format: &'static str, 310 #[serde(rename = "specVersion")] 311 spec_version: &'static str, 312 version: u32, 313 metadata: SbomMetadata, 314 components: Vec<SbomComponent>, 315 dependencies: Vec<SbomDependency>, 316 } 317 318 #[derive(Debug, Serialize)] 319 struct SbomMetadata { 320 component: SbomRootComponent, 321 } 322 323 #[derive(Debug, Serialize)] 324 struct SbomRootComponent { 325 #[serde(rename = "type")] 326 component_type: &'static str, 327 name: &'static str, 328 version: &'static str, 329 } 330 331 #[derive(Debug, Serialize)] 332 struct SbomComponent { 333 #[serde(rename = "type")] 334 component_type: &'static str, 335 #[serde(rename = "bom-ref")] 336 bom_ref: String, 337 name: String, 338 version: String, 339 #[serde(skip_serializing_if = "Option::is_none")] 340 licenses: Option<Vec<SbomLicenseChoice>>, 341 properties: Vec<SbomProperty>, 342 } 343 344 #[derive(Debug, Serialize)] 345 struct SbomLicenseChoice { 346 expression: String, 347 } 348 349 #[derive(Debug, Serialize)] 350 struct SbomProperty { 351 name: &'static str, 352 value: String, 353 } 354 355 #[derive(Debug, Serialize)] 356 struct SbomDependency { 357 #[serde(rename = "ref")] 358 reference: String, 359 #[serde(rename = "dependsOn")] 360 depends_on: Vec<String>, 361 } 362 363 fn main() { 364 if let Err(error) = run_main() { 365 eprintln!("{}: {}", error.code(), error); 366 std::process::exit(1); 367 } 368 } 369 370 fn run_main() -> Result<(), ReleaseError> { 371 let mut arguments = env::args().skip(1); 372 match arguments.next().as_deref() { 373 Some("native-release") => { 374 let args = parse_native_release_args(arguments.collect())?; 375 native_release(&workspace_root(), &args) 376 } 377 Some("rshr-step-301-gate") => { 378 let args = parse_rshr_step_301_gate_args(arguments.collect())?; 379 rshr_202_step_301_gate::run(args).map_err(|_| ReleaseError::Generation) 380 } 381 Some("rshr-step-301-platform-probe") if arguments.next().is_none() => { 382 rshr_202_step_301_platform::run().map_err(|_| ReleaseError::Generation) 383 } 384 _ => Err(ReleaseError::InvalidArguments), 385 } 386 } 387 388 fn parse_rshr_step_301_gate_args( 389 values: Vec<String>, 390 ) -> Result<rshr_202_step_301_gate::Arguments, ReleaseError> { 391 let mut step = None; 392 let mut check_id = None; 393 let mut source_revision = None; 394 let mut source_tree = None; 395 let mut candidate_digest = None; 396 let mut platform = None; 397 let mut execution_request_sha256 = None; 398 for value in values { 399 let (name, value) = value 400 .split_once('=') 401 .ok_or(ReleaseError::InvalidArguments)?; 402 let slot = match name { 403 "--check-id" => &mut check_id, 404 "--source-revision" => &mut source_revision, 405 "--source-tree" => &mut source_tree, 406 "--candidate-digest" => &mut candidate_digest, 407 "--platform" => &mut platform, 408 "--execution-request-sha256" => &mut execution_request_sha256, 409 "--step" => { 410 let parsed = value 411 .parse::<u16>() 412 .map_err(|_| ReleaseError::InvalidArguments)?; 413 if step.replace(parsed).is_some() { 414 return Err(ReleaseError::InvalidArguments); 415 } 416 continue; 417 } 418 _ => return Err(ReleaseError::InvalidArguments), 419 }; 420 if value.is_empty() || slot.replace(value.to_owned()).is_some() { 421 return Err(ReleaseError::InvalidArguments); 422 } 423 } 424 Ok(rshr_202_step_301_gate::Arguments { 425 step: step.ok_or(ReleaseError::InvalidArguments)?, 426 check_id: check_id.ok_or(ReleaseError::InvalidArguments)?, 427 source_revision: source_revision.ok_or(ReleaseError::InvalidArguments)?, 428 source_tree: source_tree.ok_or(ReleaseError::InvalidArguments)?, 429 candidate_digest: candidate_digest.ok_or(ReleaseError::InvalidArguments)?, 430 platform: platform.ok_or(ReleaseError::InvalidArguments)?, 431 execution_request_sha256: execution_request_sha256.ok_or(ReleaseError::InvalidArguments)?, 432 }) 433 } 434 435 fn workspace_root() -> PathBuf { 436 Path::new(env!("CARGO_MANIFEST_DIR")) 437 .parent() 438 .and_then(Path::parent) 439 .expect("xtask is nested at tools/xtask") 440 .to_path_buf() 441 } 442 443 fn parse_native_release_args(values: Vec<String>) -> Result<NativeReleaseArgs, ReleaseError> { 444 let mut mode = None; 445 let mut target = None; 446 let mut binary = None; 447 let mut output = None; 448 let mut source_date_epoch = None; 449 let mut index = 0; 450 while index < values.len() { 451 let value = values 452 .get(index + 1) 453 .ok_or(ReleaseError::InvalidArguments)?; 454 match values[index].as_str() { 455 "--mode" => { 456 let parsed = match value.as_str() { 457 "check" => Mode::Check, 458 "write" => Mode::Write, 459 _ => return Err(ReleaseError::InvalidArguments), 460 }; 461 if mode.replace(parsed).is_some() { 462 return Err(ReleaseError::InvalidArguments); 463 } 464 } 465 "--target" => { 466 if target.replace(value.clone()).is_some() { 467 return Err(ReleaseError::InvalidArguments); 468 } 469 } 470 "--binary" => { 471 if binary.replace(PathBuf::from(value)).is_some() { 472 return Err(ReleaseError::InvalidArguments); 473 } 474 } 475 "--output" => { 476 if output.replace(PathBuf::from(value)).is_some() { 477 return Err(ReleaseError::InvalidArguments); 478 } 479 } 480 "--source-date-epoch" => { 481 let parsed = value 482 .parse::<u32>() 483 .ok() 484 .filter(|value| *value > 0) 485 .ok_or(ReleaseError::InvalidArguments)?; 486 if source_date_epoch.replace(parsed).is_some() { 487 return Err(ReleaseError::InvalidArguments); 488 } 489 } 490 _ => return Err(ReleaseError::InvalidArguments), 491 } 492 index += 2; 493 } 494 let args = NativeReleaseArgs { 495 mode: mode.ok_or(ReleaseError::InvalidArguments)?, 496 target: target.ok_or(ReleaseError::InvalidArguments)?, 497 binary: binary.ok_or(ReleaseError::InvalidArguments)?, 498 output: output.ok_or(ReleaseError::InvalidArguments)?, 499 source_date_epoch: source_date_epoch.ok_or(ReleaseError::InvalidArguments)?, 500 }; 501 if !SUPPORTED_TARGETS.contains(&args.target.as_str()) 502 || !args.binary.is_absolute() 503 || !args.output.is_absolute() 504 { 505 return Err(ReleaseError::InvalidArguments); 506 } 507 Ok(args) 508 } 509 510 fn native_release(root: &Path, args: &NativeReleaseArgs) -> Result<(), ReleaseError> { 511 validate_source_root(root)?; 512 validate_clean_source(root)?; 513 let initial_head = git_capture(root, &["rev-parse", "HEAD"], 128)?; 514 let initial_head = exact_line(&initial_head).ok_or(ReleaseError::InvalidSource)?; 515 if !lower_hex(initial_head, 40) { 516 return Err(ReleaseError::InvalidSource); 517 } 518 validate_binary(&args.binary, &args.target)?; 519 validate_output_path(root, &args.output)?; 520 let source_lock = read_source_lock(root)?; 521 let metadata = cargo_metadata(root)?; 522 validate_metadata(&metadata)?; 523 524 let parent = args.output.parent().ok_or(ReleaseError::InvalidOutput)?; 525 let staging = tempfile::Builder::new() 526 .prefix(".rhi-native-release-") 527 .tempdir_in(parent) 528 .map_err(|_| ReleaseError::Generation)?; 529 let stage = staging.path(); 530 set_directory_permissions(stage)?; 531 532 copy_bounded( 533 &root.join("LICENSE"), 534 &stage.join("LICENSE"), 535 MAX_TEXT_BYTES, 536 )?; 537 copy_bounded( 538 &root.join(CONFIG_EXAMPLE), 539 &stage.join("config.example.toml"), 540 MAX_TEXT_BYTES, 541 )?; 542 copy_bounded( 543 &root.join(CONFIG_SCHEMA), 544 &stage.join("config.schema.json"), 545 MAX_TEXT_BYTES, 546 )?; 547 copy_bounded( 548 &root.join(SYSTEMD_UNIT), 549 &stage.join("systemd.service"), 550 MAX_TEXT_BYTES, 551 )?; 552 copy_bounded( 553 &root.join(SOURCE_LOCK), 554 &stage.join(SOURCE_LOCK), 555 MAX_TEXT_BYTES, 556 )?; 557 create_binary_archive( 558 &args.binary, 559 &stage.join("binary.tar.gz"), 560 &args.target, 561 args.source_date_epoch, 562 )?; 563 create_source_archive( 564 root, 565 &stage.join("service-source.tar.gz"), 566 args.source_date_epoch, 567 )?; 568 let (sbom, notices) = supply_chain_documents(&metadata)?; 569 write_json(&stage.join("sbom.cdx.json"), &sbom)?; 570 write_generated(&stage.join("THIRD-PARTY-NOTICES.txt"), notices.as_bytes())?; 571 572 let source_lock_sha256 = hash_regular(&stage.join(SOURCE_LOCK), MAX_TEXT_BYTES)?.sha256; 573 let payload = inventory_records(stage)?; 574 let manifest = ArtifactManifest { 575 schema: "radroots.service.release-artifacts.v1", 576 contract_version: 1, 577 service: SERVICE, 578 version: VERSION, 579 target: args.target.clone(), 580 source_date_epoch: args.source_date_epoch, 581 service_repository: REPOSITORY, 582 service_revision: initial_head.to_owned(), 583 lib_repository: source_lock.repository.clone(), 584 lib_revision: source_lock.revision.clone(), 585 rust_version: RUST_VERSION, 586 host_feature_profile: HOST_FEATURE_PROFILE, 587 contract_versions: source_lock.contract_versions.clone(), 588 protected_material_included: false, 589 nix_qualified: true, 590 oci_included: false, 591 artifacts: payload, 592 }; 593 write_json(&stage.join("artifact-manifest.v1.json"), &manifest)?; 594 let manifest_sha256 = 595 hash_regular(&stage.join("artifact-manifest.v1.json"), MAX_DOCUMENT_BYTES)?.sha256; 596 let provenance = ProvenanceInput { 597 schema: "radroots.service.provenance-input.v1", 598 contract_version: 1, 599 predicate_type: "https://slsa.dev/provenance/v1", 600 build_type: "https://radroots.dev/contracts/rhi-native-release/v2", 601 builder_id: "https://radroots.dev/builders/rhi-native-release/v2", 602 service: SERVICE, 603 version: VERSION, 604 target: args.target.clone(), 605 source_date_epoch: args.source_date_epoch, 606 service_repository: REPOSITORY, 607 service_revision: initial_head.to_owned(), 608 lib_repository: source_lock.repository, 609 lib_revision: source_lock.revision, 610 source_lock_sha256, 611 manifest_sha256, 612 subjects: inventory_records(stage)?, 613 signing_required: true, 614 signed: false, 615 }; 616 write_json(&stage.join("provenance-input.v1.json"), &provenance)?; 617 write_checksums(stage)?; 618 validate_exact_inventory(stage)?; 619 let expected = inventory_records(stage)?; 620 621 validate_clean_source(root)?; 622 if exact_line(&git_capture(root, &["rev-parse", "HEAD"], 128)?) != Some(initial_head) { 623 return Err(ReleaseError::DirtySource); 624 } 625 626 if args.output.exists() { 627 compare_output(&args.output, &expected)?; 628 sync_directory(&args.output)?; 629 sync_directory(parent)?; 630 return Ok(()); 631 } 632 if args.mode == Mode::Check { 633 return Err(ReleaseError::StaleOutput); 634 } 635 sync_directory(stage)?; 636 publish_directory(stage, &args.output)?; 637 sync_directory(parent)?; 638 compare_output(&args.output, &expected) 639 } 640 641 fn validate_source_root(root: &Path) -> Result<(), ReleaseError> { 642 if !root.is_absolute() 643 || fs::symlink_metadata(root) 644 .map(|metadata| metadata.file_type().is_symlink() || !metadata.is_dir()) 645 .unwrap_or(true) 646 || root.join("docs").exists() 647 || root.join(".github").exists() 648 || root.join(".act").exists() 649 { 650 return Err(ReleaseError::InvalidSource); 651 } 652 for required in [ 653 "Cargo.toml", 654 "Cargo.lock", 655 "LICENSE", 656 SOURCE_LOCK, 657 CONFIG_EXAMPLE, 658 CONFIG_SCHEMA, 659 SYSTEMD_UNIT, 660 ] { 661 validate_regular( 662 &root.join(required), 663 MAX_DOCUMENT_BYTES, 664 ReleaseError::InvalidSource, 665 )?; 666 } 667 Ok(()) 668 } 669 670 fn validate_clean_source(root: &Path) -> Result<(), ReleaseError> { 671 for arguments in [ 672 &["diff", "--quiet", "--"] as &[&str], 673 &["diff", "--cached", "--quiet", "--"], 674 ] { 675 let status = Command::new("git") 676 .args(arguments) 677 .current_dir(root) 678 .stdin(Stdio::null()) 679 .stdout(Stdio::null()) 680 .stderr(Stdio::null()) 681 .status() 682 .map_err(|_| ReleaseError::DirtySource)?; 683 if !status.success() { 684 return Err(ReleaseError::DirtySource); 685 } 686 } 687 let untracked = command_capture_bounded( 688 Command::new("git") 689 .args(["ls-files", "--others", "--exclude-standard", "-z"]) 690 .current_dir(root), 691 1, 692 ReleaseError::DirtySource, 693 )?; 694 if !untracked.is_empty() { 695 return Err(ReleaseError::DirtySource); 696 } 697 Ok(()) 698 } 699 700 fn validate_binary(path: &Path, target: &str) -> Result<(), ReleaseError> { 701 open_binary(path, target).map(|_| ()) 702 } 703 704 fn validate_output_path(root: &Path, output: &Path) -> Result<(), ReleaseError> { 705 let parent = output.parent().ok_or(ReleaseError::InvalidOutput)?; 706 let canonical_root = fs::canonicalize(root).map_err(|_| ReleaseError::InvalidSource)?; 707 let canonical_parent = fs::canonicalize(parent).map_err(|_| ReleaseError::InvalidOutput)?; 708 if output == Path::new("/") 709 || output.components().any(|component| { 710 matches!( 711 component, 712 std::path::Component::CurDir 713 | std::path::Component::ParentDir 714 | std::path::Component::Prefix(_) 715 ) 716 }) 717 || canonical_parent.starts_with(canonical_root) 718 || fs::symlink_metadata(parent) 719 .map(|metadata| metadata.file_type().is_symlink() || !metadata.is_dir()) 720 .unwrap_or(true) 721 || output 722 .file_name() 723 .and_then(|value| value.to_str()) 724 .is_none_or(|value| value.is_empty() || value == "." || value == "..") 725 { 726 return Err(ReleaseError::InvalidOutput); 727 } 728 match fs::symlink_metadata(output) { 729 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { 730 return Err(ReleaseError::InvalidOutput); 731 } 732 Ok(_) => {} 733 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 734 Err(_) => return Err(ReleaseError::InvalidOutput), 735 } 736 Ok(()) 737 } 738 739 fn read_source_lock(root: &Path) -> Result<SourceLock, ReleaseError> { 740 let bytes = read_bounded( 741 &root.join(SOURCE_LOCK), 742 MAX_TEXT_BYTES, 743 ReleaseError::InvalidSourceLock, 744 )?; 745 let text = std::str::from_utf8(&bytes).map_err(|_| ReleaseError::InvalidSourceLock)?; 746 let lock: SourceLock = toml::from_str(text).map_err(|_| ReleaseError::InvalidSourceLock)?; 747 let cargo_lock = hash_regular(&root.join("Cargo.lock"), MAX_DOCUMENT_BYTES)?; 748 let flake_lock = hash_regular(&root.join("flake.lock"), MAX_DOCUMENT_BYTES)?; 749 let artifact_contract = hash_regular( 750 &root.join("contracts/release/rhi-artifact-contract.v3.json"), 751 MAX_DOCUMENT_BYTES, 752 )?; 753 let revisions = cargo_dependency_revisions(root)?; 754 if lock.schema != "radroots.service.source-lock.v3" 755 || lock.contract_version != 3 756 || lock.service != SERVICE 757 || lock.repository != "https://github.com/radrootslabs/lib" 758 || !lower_hex(&lock.revision, 40) 759 || lock.architecture != "radroots.crates.release.v2" 760 || !lower_hex(&lock.workspace_catalog_sha256, 64) 761 || lock.version != "0.1.0-alpha" 762 || !lower_hex(&lock.source_archive_sha256, 64) 763 || lock.cargo_lock_sha256 != cargo_lock.sha256 764 || lock.rust_version != RUST_VERSION 765 || lock.host_feature_profile != HOST_FEATURE_PROFILE 766 || lock.source_archive_contract.binding 767 != "sha256_of_canonical_exact_lib_revision_tree_archive" 768 || lock.source_archive_contract.format != "ustar" 769 || lock.source_archive_contract.compression != "none" 770 || lock.source_archive_contract.compression_timestamp != "not_applicable" 771 || lock.source_archive_contract.entry_order != "bytewise_git_path" 772 || lock.source_archive_contract.path_prefix != "none" 773 || lock.source_archive_contract.file_mode != "git_index_100644_or_100755" 774 || lock.source_archive_contract.uid != 0 775 || lock.source_archive_contract.gid != 0 776 || !lock.source_archive_contract.uname.is_empty() 777 || !lock.source_archive_contract.gname.is_empty() 778 || lock.source_archive_contract.mtime != "lib_revision_commit_timestamp" 779 || lock.source_archive_contract.pax_headers != "forbidden" 780 || lock.source_archive_contract.directory_entries != "omitted" 781 || lock.source_archive_contract.symlinks != "forbidden" 782 || lock.source_archive_contract.hardlinks != "forbidden" 783 || lock.source_archive_contract.submodules != "forbidden" 784 || lock.source_archive_contract.trailer != "two_zero_blocks" 785 || lock.nix.material != "qualified" 786 || lock.nix.lib_revision != lock.revision 787 || lock.nix.supported_systems != ["aarch64-darwin", "x86_64-linux"] 788 || lock.nix.public_input_lock.path != "flake.lock" 789 || lock.nix.public_input_lock.sha256 != flake_lock.sha256 790 || lock.nix.public_input_lock.binding != "exact_regular_file_bytes" 791 || lock.nix.public_input_lock.mutable_reference != "forbidden" 792 || lock.nix.public_input_lock.lib_input != "lib" 793 || lock.nix.parent_result.embedded_in_public_input_lock 794 || lock.nix.parent_result.embedded_in_source_lock 795 || lock.nix.parent_result.storage != "separate_generation_scoped_evidence" 796 || lock.artifact_contract.path != "contracts/release/rhi-artifact-contract.v3.json" 797 || lock.artifact_contract.sha256 != artifact_contract.sha256 798 || lock.artifact_contract.binding != "exact_regular_file_bytes_in_same_source_revision" 799 || lock.sqlite.high_level_authority != "sqlx_only" 800 || lock 801 .sqlite 802 .second_pool_connection_query_transaction_migration_authority 803 != "forbidden" 804 || lock.sqlite.incremental_backup_adapter != "sealed_native_sqlx_owned_locked_handle_only" 805 || lock.sqlite.native_linkage_count != 1 806 || revisions != BTreeSet::from([lock.revision.clone()]) 807 || [ 808 lock.contract_versions.config, 809 lock.contract_versions.state, 810 lock.contract_versions.admin, 811 lock.contract_versions.status, 812 lock.contract_versions.provider, 813 ] 814 .contains(&0) 815 { 816 return Err(ReleaseError::InvalidSourceLock); 817 } 818 Ok(lock) 819 } 820 821 fn cargo_dependency_revisions(root: &Path) -> Result<BTreeSet<String>, ReleaseError> { 822 let bytes = read_bounded( 823 &root.join("Cargo.toml"), 824 MAX_TEXT_BYTES, 825 ReleaseError::InvalidSourceLock, 826 )?; 827 let value: toml::Value = 828 toml::from_str(std::str::from_utf8(&bytes).map_err(|_| ReleaseError::InvalidSourceLock)?) 829 .map_err(|_| ReleaseError::InvalidSourceLock)?; 830 let dependencies = value 831 .get("dependencies") 832 .and_then(toml::Value::as_table) 833 .ok_or(ReleaseError::InvalidSourceLock)?; 834 let mut revisions = BTreeSet::new(); 835 let mut count = 0_usize; 836 for (name, dependency) in dependencies { 837 if !name.starts_with("radroots_") { 838 continue; 839 } 840 count += 1; 841 let table = dependency 842 .as_table() 843 .ok_or(ReleaseError::InvalidSourceLock)?; 844 if table.get("git").and_then(toml::Value::as_str) 845 != Some("https://github.com/radrootslabs/lib") 846 || table.contains_key("path") 847 || table.contains_key("branch") 848 || table.contains_key("tag") 849 { 850 return Err(ReleaseError::InvalidSourceLock); 851 } 852 revisions.insert( 853 table 854 .get("rev") 855 .and_then(toml::Value::as_str) 856 .filter(|revision| lower_hex(revision, 40)) 857 .ok_or(ReleaseError::InvalidSourceLock)? 858 .to_owned(), 859 ); 860 } 861 if count != RADROOTS_DEPENDENCY_COUNT { 862 return Err(ReleaseError::InvalidSourceLock); 863 } 864 Ok(revisions) 865 } 866 867 fn cargo_metadata(root: &Path) -> Result<CargoMetadata, ReleaseError> { 868 let bytes = command_capture_bounded( 869 Command::new("cargo") 870 .args(["metadata", "--format-version", "1", "--locked", "--offline"]) 871 .current_dir(root), 872 MAX_METADATA_BYTES, 873 ReleaseError::InvalidMetadata, 874 )?; 875 serde_json::from_slice(&bytes).map_err(|_| ReleaseError::InvalidMetadata) 876 } 877 878 fn validate_metadata(metadata: &CargoMetadata) -> Result<(), ReleaseError> { 879 if metadata.packages.is_empty() 880 || metadata.packages.len() > MAX_PACKAGES 881 || metadata.workspace_members.len() != 2 882 || metadata.resolve.is_none() 883 || !metadata 884 .packages 885 .iter() 886 .any(|package| package.name == SERVICE && package.version == VERSION) 887 { 888 return Err(ReleaseError::InvalidMetadata); 889 } 890 Ok(()) 891 } 892 893 fn create_binary_archive( 894 binary: &Path, 895 output: &Path, 896 target: &str, 897 epoch: u32, 898 ) -> Result<(), ReleaseError> { 899 let mut input = open_binary(binary, target)?; 900 let metadata = input.metadata().map_err(|_| ReleaseError::InvalidBinary)?; 901 let file = create_new(output)?; 902 let encoder = GzBuilder::new().mtime(epoch).write( 903 BoundedWriter::new(file, MAX_BINARY_BYTES + MAX_TEXT_BYTES), 904 Compression::best(), 905 ); 906 let mut tar = TarBuilder::new(encoder); 907 tar.mode(tar::HeaderMode::Deterministic); 908 let mut header = TarHeader::new_gnu(); 909 header.set_size(metadata.len()); 910 header.set_mode(0o755); 911 header.set_uid(0); 912 header.set_gid(0); 913 header.set_mtime(u64::from(epoch)); 914 header.set_cksum(); 915 tar.append_data( 916 &mut header, 917 format!("rhi-{VERSION}-{target}/rhi"), 918 &mut input, 919 ) 920 .map_err(|_| ReleaseError::Generation)?; 921 let encoder = tar.into_inner().map_err(|_| ReleaseError::Generation)?; 922 encoder 923 .finish() 924 .map_err(|_| ReleaseError::Generation)? 925 .sync_all() 926 .map_err(|_| ReleaseError::Generation) 927 } 928 929 fn create_source_archive(root: &Path, output: &Path, epoch: u32) -> Result<(), ReleaseError> { 930 let work = TempDir::new().map_err(|_| ReleaseError::Generation)?; 931 let source = work.path().join(format!("rhi-{VERSION}-source")); 932 fs::create_dir(&source).map_err(|_| ReleaseError::Generation)?; 933 extract_exact_head(root, &source, work.path())?; 934 scan_source_tree(&source)?; 935 let tracked = count_tree(&source)?; 936 if tracked == 0 || tracked > MAX_TRACKED_FILES { 937 return Err(ReleaseError::InvalidSource); 938 } 939 let vendor_config = command_capture_bounded( 940 Command::new("cargo") 941 .args(["vendor", "--locked", "--versioned-dirs", "vendor"]) 942 .current_dir(&source), 943 MAX_TEXT_BYTES, 944 ReleaseError::Generation, 945 )?; 946 let cargo_config = source.join(".cargo/config.toml"); 947 let mut config = read_bounded(&cargo_config, MAX_TEXT_BYTES, ReleaseError::Generation)?; 948 config.extend_from_slice(b"\n"); 949 config.extend_from_slice(&vendor_config); 950 if config.len() as u64 > MAX_TEXT_BYTES { 951 return Err(ReleaseError::Generation); 952 } 953 fs::write(&cargo_config, &config).map_err(|_| ReleaseError::Generation)?; 954 let _ = command_capture_bounded( 955 Command::new("cargo") 956 .args(["metadata", "--format-version", "1", "--locked", "--offline"]) 957 .current_dir(&source), 958 MAX_METADATA_BYTES, 959 ReleaseError::Generation, 960 )?; 961 create_tree_archive(&source, output, epoch) 962 } 963 964 fn extract_exact_head(root: &Path, destination: &Path, work: &Path) -> Result<(), ReleaseError> { 965 let archive = work.join("source-head.tar"); 966 let archive_file = fs::OpenOptions::new() 967 .create_new(true) 968 .write(true) 969 .open(&archive) 970 .map_err(|_| ReleaseError::Generation)?; 971 let status = Command::new("git") 972 .args(["archive", "--format=tar", "HEAD"]) 973 .current_dir(root) 974 .stdin(Stdio::null()) 975 .stdout(Stdio::from(archive_file)) 976 .stderr(Stdio::null()) 977 .status() 978 .map_err(|_| ReleaseError::InvalidSource)?; 979 if !status.success() { 980 return Err(ReleaseError::InvalidSource); 981 } 982 validate_regular( 983 &archive, 984 MAX_SOURCE_ARCHIVE_BYTES, 985 ReleaseError::InvalidSource, 986 )?; 987 let file = fs::File::open(archive).map_err(|_| ReleaseError::InvalidSource)?; 988 tar::Archive::new(file) 989 .unpack(destination) 990 .map_err(|_| ReleaseError::InvalidSource) 991 } 992 993 fn count_tree(root: &Path) -> Result<usize, ReleaseError> { 994 let mut count = 0_usize; 995 let mut pending = vec![root.to_path_buf()]; 996 while let Some(directory) = pending.pop() { 997 let entries = fs::read_dir(directory).map_err(|_| ReleaseError::InvalidSource)?; 998 for entry in entries { 999 let entry = entry.map_err(|_| ReleaseError::InvalidSource)?; 1000 let kind = entry.file_type().map_err(|_| ReleaseError::InvalidSource)?; 1001 if kind.is_symlink() || (!kind.is_file() && !kind.is_dir()) { 1002 return Err(ReleaseError::InvalidSource); 1003 } 1004 if kind.is_dir() { 1005 pending.push(entry.path()); 1006 } else { 1007 count = count.checked_add(1).ok_or(ReleaseError::InvalidSource)?; 1008 if count > MAX_TRACKED_FILES { 1009 return Err(ReleaseError::InvalidSource); 1010 } 1011 } 1012 } 1013 } 1014 Ok(count) 1015 } 1016 1017 #[cfg(unix)] 1018 fn open_binary(path: &Path, target: &str) -> Result<fs::File, ReleaseError> { 1019 use rustix::fs::{Mode as FileMode, OFlags}; 1020 use std::io::Seek as _; 1021 use std::os::unix::fs::PermissionsExt as _; 1022 1023 let descriptor = rustix::fs::open( 1024 path, 1025 OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, 1026 FileMode::empty(), 1027 ) 1028 .map_err(|_| ReleaseError::InvalidBinary)?; 1029 let mut file = fs::File::from(descriptor); 1030 let metadata = file.metadata().map_err(|_| ReleaseError::InvalidBinary)?; 1031 if !metadata.is_file() 1032 || metadata.len() < 20 1033 || metadata.len() > MAX_BINARY_BYTES 1034 || metadata.permissions().mode() & 0o111 == 0 1035 { 1036 return Err(ReleaseError::InvalidBinary); 1037 } 1038 let mut header = [0_u8; 20]; 1039 file.read_exact(&mut header) 1040 .map_err(|_| ReleaseError::InvalidBinary)?; 1041 file.rewind().map_err(|_| ReleaseError::InvalidBinary)?; 1042 let expected_machine = match target { 1043 "x86_64-unknown-linux-gnu" => 62_u16, 1044 "aarch64-unknown-linux-gnu" => 183_u16, 1045 _ => return Err(ReleaseError::InvalidBinary), 1046 }; 1047 if header[..4] != [0x7f, b'E', b'L', b'F'] 1048 || header[4] != 2 1049 || header[5] != 1 1050 || header[6] != 1 1051 || ![0_u8, 3_u8].contains(&header[7]) 1052 || ![2_u16, 3_u16].contains(&u16::from_le_bytes([header[16], header[17]])) 1053 || u16::from_le_bytes([header[18], header[19]]) != expected_machine 1054 { 1055 return Err(ReleaseError::InvalidBinary); 1056 } 1057 let mut scanner = SecretScanner::default(); 1058 let mut total = 0_u64; 1059 let mut buffer = [0_u8; COPY_BUFFER_BYTES]; 1060 loop { 1061 let read = file 1062 .read(&mut buffer) 1063 .map_err(|_| ReleaseError::InvalidBinary)?; 1064 if read == 0 { 1065 break; 1066 } 1067 total = total 1068 .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidBinary)?) 1069 .ok_or(ReleaseError::InvalidBinary)?; 1070 if total > MAX_BINARY_BYTES { 1071 return Err(ReleaseError::InvalidBinary); 1072 } 1073 scanner.scan(&buffer[..read])?; 1074 } 1075 if total != metadata.len() { 1076 return Err(ReleaseError::InvalidBinary); 1077 } 1078 file.rewind().map_err(|_| ReleaseError::InvalidBinary)?; 1079 Ok(file) 1080 } 1081 1082 #[cfg(not(unix))] 1083 fn open_binary(_path: &Path, _target: &str) -> Result<fs::File, ReleaseError> { 1084 Err(ReleaseError::InvalidBinary) 1085 } 1086 1087 fn create_tree_archive(source: &Path, output: &Path, epoch: u32) -> Result<(), ReleaseError> { 1088 let file = create_new(output)?; 1089 let encoder = GzBuilder::new().mtime(epoch).write( 1090 BoundedWriter::new(file, MAX_SOURCE_ARCHIVE_BYTES), 1091 Compression::best(), 1092 ); 1093 let mut tar = TarBuilder::new(encoder); 1094 tar.mode(tar::HeaderMode::Deterministic); 1095 let root_name = source.file_name().ok_or(ReleaseError::Generation)?; 1096 append_tree(&mut tar, source, Path::new(root_name), epoch)?; 1097 let encoder = tar.into_inner().map_err(|_| ReleaseError::Generation)?; 1098 encoder 1099 .finish() 1100 .map_err(|_| ReleaseError::Generation)? 1101 .sync_all() 1102 .map_err(|_| ReleaseError::Generation) 1103 } 1104 1105 fn append_tree<W: std::io::Write>( 1106 tar: &mut TarBuilder<W>, 1107 source: &Path, 1108 archive_path: &Path, 1109 epoch: u32, 1110 ) -> Result<(), ReleaseError> { 1111 let mut entries = fs::read_dir(source) 1112 .map_err(|_| ReleaseError::Generation)? 1113 .collect::<Result<Vec<_>, _>>() 1114 .map_err(|_| ReleaseError::Generation)?; 1115 entries.sort_by_key(fs::DirEntry::file_name); 1116 for entry in entries { 1117 let file_type = entry.file_type().map_err(|_| ReleaseError::Generation)?; 1118 let path = entry.path(); 1119 let member = archive_path.join(entry.file_name()); 1120 if file_type.is_symlink() { 1121 return Err(ReleaseError::Generation); 1122 } 1123 if file_type.is_dir() { 1124 append_tree(tar, &path, &member, epoch)?; 1125 continue; 1126 } 1127 if !file_type.is_file() { 1128 return Err(ReleaseError::Generation); 1129 } 1130 let metadata = entry.metadata().map_err(|_| ReleaseError::Generation)?; 1131 let mut file = fs::File::open(path).map_err(|_| ReleaseError::Generation)?; 1132 let mut header = TarHeader::new_gnu(); 1133 header.set_size(metadata.len()); 1134 header.set_mode(0o644); 1135 header.set_uid(0); 1136 header.set_gid(0); 1137 header.set_mtime(u64::from(epoch)); 1138 header.set_cksum(); 1139 tar.append_data(&mut header, member, &mut file) 1140 .map_err(|_| ReleaseError::Generation)?; 1141 } 1142 Ok(()) 1143 } 1144 1145 fn supply_chain_documents( 1146 metadata: &CargoMetadata, 1147 ) -> Result<(CycloneDxBom, String), ReleaseError> { 1148 validate_metadata(metadata)?; 1149 let workspace = metadata 1150 .workspace_members 1151 .iter() 1152 .cloned() 1153 .collect::<BTreeSet<_>>(); 1154 let mut packages = metadata.packages.clone(); 1155 packages.sort_by(|left, right| left.id.cmp(&right.id)); 1156 let package_references = packages 1157 .iter() 1158 .map(|package| { 1159 let is_workspace = workspace.contains(&package.id); 1160 if !is_workspace && package.source.is_none() { 1161 return Err(ReleaseError::InvalidMetadata); 1162 } 1163 Ok(( 1164 package.id.clone(), 1165 stable_package_reference(package, is_workspace)?, 1166 )) 1167 }) 1168 .collect::<Result<BTreeMap<_, _>, _>>()?; 1169 if package_references.len() != packages.len() 1170 || package_references.values().collect::<BTreeSet<_>>().len() != packages.len() 1171 { 1172 return Err(ReleaseError::InvalidMetadata); 1173 } 1174 let mut components = Vec::with_capacity(packages.len()); 1175 let mut notices = String::from( 1176 "THIRD-PARTY NOTICES\n\nGenerated from the exact locked Cargo graph. License expressions are package metadata; packaged vendored source is authoritative for license texts.\n\n", 1177 ); 1178 for package in packages { 1179 let package_reference = package_references 1180 .get(&package.id) 1181 .ok_or(ReleaseError::InvalidMetadata)? 1182 .clone(); 1183 let mut properties = vec![SbomProperty { 1184 name: "radroots:cargo_component_ref", 1185 value: package_reference.clone(), 1186 }]; 1187 if let Some(source) = package.source { 1188 properties.push(SbomProperty { 1189 name: "radroots:cargo_source", 1190 value: source, 1191 }); 1192 } 1193 if let Some(checksum) = package.checksum { 1194 properties.push(SbomProperty { 1195 name: "radroots:cargo_checksum", 1196 value: checksum, 1197 }); 1198 } 1199 properties.push(SbomProperty { 1200 name: "radroots:workspace_member", 1201 value: workspace.contains(&package.id).to_string(), 1202 }); 1203 let licenses = package.license.as_ref().map(|license| { 1204 vec![SbomLicenseChoice { 1205 expression: license.clone(), 1206 }] 1207 }); 1208 use fmt::Write as _; 1209 writeln!( 1210 notices, 1211 "{} {} — {}", 1212 package.name, 1213 package.version, 1214 package.license.as_deref().unwrap_or("NOASSERTION") 1215 ) 1216 .map_err(|_| ReleaseError::Generation)?; 1217 components.push(SbomComponent { 1218 component_type: "library", 1219 bom_ref: package_reference, 1220 name: package.name, 1221 version: package.version, 1222 licenses, 1223 properties, 1224 }); 1225 } 1226 let mut dependencies = metadata 1227 .resolve 1228 .as_ref() 1229 .ok_or(ReleaseError::InvalidMetadata)? 1230 .nodes 1231 .iter() 1232 .map(|node| -> Result<SbomDependency, ReleaseError> { 1233 let reference = package_references 1234 .get(&node.id) 1235 .ok_or(ReleaseError::InvalidMetadata)? 1236 .clone(); 1237 let mut depends_on = node 1238 .dependencies 1239 .iter() 1240 .map(|dependency| { 1241 package_references 1242 .get(dependency) 1243 .cloned() 1244 .ok_or(ReleaseError::InvalidMetadata) 1245 }) 1246 .collect::<Result<Vec<_>, _>>()?; 1247 depends_on.sort(); 1248 depends_on.dedup(); 1249 Ok(SbomDependency { 1250 reference, 1251 depends_on, 1252 }) 1253 }) 1254 .collect::<Result<Vec<_>, _>>()?; 1255 dependencies.sort_by(|left, right| left.reference.cmp(&right.reference)); 1256 Ok(( 1257 CycloneDxBom { 1258 bom_format: "CycloneDX", 1259 spec_version: "1.5", 1260 version: 1, 1261 metadata: SbomMetadata { 1262 component: SbomRootComponent { 1263 component_type: "application", 1264 name: SERVICE, 1265 version: VERSION, 1266 }, 1267 }, 1268 components, 1269 dependencies, 1270 }, 1271 notices, 1272 )) 1273 } 1274 1275 fn stable_package_reference( 1276 package: &CargoPackage, 1277 is_workspace: bool, 1278 ) -> Result<String, ReleaseError> { 1279 let mut hasher = Sha256::new(); 1280 hasher.update(b"radroots.service.native_release.cargo_component.v1\0"); 1281 hash_framed(&mut hasher, package.name.as_bytes())?; 1282 hash_framed(&mut hasher, package.version.as_bytes())?; 1283 hash_framed( 1284 &mut hasher, 1285 if is_workspace { 1286 b"workspace" 1287 } else { 1288 package 1289 .source 1290 .as_deref() 1291 .ok_or(ReleaseError::InvalidMetadata)? 1292 .as_bytes() 1293 }, 1294 )?; 1295 hash_framed( 1296 &mut hasher, 1297 package.checksum.as_deref().unwrap_or("").as_bytes(), 1298 )?; 1299 Ok(format!( 1300 "urn:radroots:cargo-component:sha256:{}", 1301 hex::encode(hasher.finalize()) 1302 )) 1303 } 1304 1305 fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), ReleaseError> { 1306 let length = u64::try_from(bytes.len()).map_err(|_| ReleaseError::InvalidMetadata)?; 1307 hasher.update(length.to_be_bytes()); 1308 hasher.update(bytes); 1309 Ok(()) 1310 } 1311 1312 #[cfg(test)] 1313 fn validate_relative(value: &str) -> Result<(), ReleaseError> { 1314 let path = Path::new(value); 1315 if value.is_empty() 1316 || path.is_absolute() 1317 || path.components().any(|component| { 1318 matches!( 1319 component, 1320 std::path::Component::ParentDir 1321 | std::path::Component::RootDir 1322 | std::path::Component::Prefix(_) 1323 ) 1324 }) 1325 { 1326 return Err(ReleaseError::InvalidSource); 1327 } 1328 Ok(()) 1329 } 1330 1331 fn copy_bounded(source: &Path, output: &Path, maximum: u64) -> Result<(), ReleaseError> { 1332 validate_regular(source, maximum, ReleaseError::InvalidSource)?; 1333 let metadata = fs::metadata(source).map_err(|_| ReleaseError::InvalidSource)?; 1334 let mut input = fs::File::open(source).map_err(|_| ReleaseError::InvalidSource)?; 1335 let mut target = create_new(output)?; 1336 let mut scanner = SecretScanner::default(); 1337 let mut total = 0_u64; 1338 let mut buffer = [0_u8; COPY_BUFFER_BYTES]; 1339 loop { 1340 let read = input 1341 .read(&mut buffer) 1342 .map_err(|_| ReleaseError::InvalidSource)?; 1343 if read == 0 { 1344 break; 1345 } 1346 total = total 1347 .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidSource)?) 1348 .ok_or(ReleaseError::InvalidSource)?; 1349 if total > maximum { 1350 return Err(ReleaseError::InvalidSource); 1351 } 1352 scanner.scan(&buffer[..read])?; 1353 target 1354 .write_all(&buffer[..read]) 1355 .map_err(|_| ReleaseError::Generation)?; 1356 } 1357 if total != metadata.len() { 1358 return Err(ReleaseError::InvalidSource); 1359 } 1360 target.sync_all().map_err(|_| ReleaseError::Generation) 1361 } 1362 1363 fn scan_source_tree(root: &Path) -> Result<(), ReleaseError> { 1364 let mut pending = vec![root.to_path_buf()]; 1365 let mut file_count = 0_usize; 1366 while let Some(directory) = pending.pop() { 1367 let entries = fs::read_dir(directory).map_err(|_| ReleaseError::InvalidSource)?; 1368 for entry in entries { 1369 let entry = entry.map_err(|_| ReleaseError::InvalidSource)?; 1370 let kind = entry.file_type().map_err(|_| ReleaseError::InvalidSource)?; 1371 if kind.is_symlink() || (!kind.is_file() && !kind.is_dir()) { 1372 return Err(ReleaseError::InvalidSource); 1373 } 1374 if kind.is_dir() { 1375 pending.push(entry.path()); 1376 continue; 1377 } 1378 file_count = file_count 1379 .checked_add(1) 1380 .ok_or(ReleaseError::InvalidSource)?; 1381 if file_count > MAX_TRACKED_FILES { 1382 return Err(ReleaseError::InvalidSource); 1383 } 1384 let path = entry.path(); 1385 validate_regular(&path, MAX_DOCUMENT_BYTES, ReleaseError::InvalidSource)?; 1386 let mut file = fs::File::open(path).map_err(|_| ReleaseError::InvalidSource)?; 1387 let mut scanner = SecretScanner::default(); 1388 let mut buffer = [0_u8; COPY_BUFFER_BYTES]; 1389 loop { 1390 let read = file 1391 .read(&mut buffer) 1392 .map_err(|_| ReleaseError::InvalidSource)?; 1393 if read == 0 { 1394 break; 1395 } 1396 scanner.scan(&buffer[..read])?; 1397 } 1398 } 1399 } 1400 Ok(()) 1401 } 1402 1403 fn create_new(path: &Path) -> Result<fs::File, ReleaseError> { 1404 let mut options = fs::OpenOptions::new(); 1405 options.create_new(true).write(true); 1406 #[cfg(unix)] 1407 { 1408 use std::os::unix::fs::OpenOptionsExt as _; 1409 options.mode(0o644); 1410 } 1411 let file = options.open(path).map_err(|_| ReleaseError::Generation)?; 1412 set_file_permissions(&file)?; 1413 Ok(file) 1414 } 1415 1416 #[cfg(unix)] 1417 fn set_file_permissions(file: &fs::File) -> Result<(), ReleaseError> { 1418 use std::os::unix::fs::PermissionsExt as _; 1419 1420 file.set_permissions(fs::Permissions::from_mode(0o644)) 1421 .map_err(|_| ReleaseError::Generation) 1422 } 1423 1424 #[cfg(not(unix))] 1425 fn set_file_permissions(_file: &fs::File) -> Result<(), ReleaseError> { 1426 Ok(()) 1427 } 1428 1429 #[cfg(unix)] 1430 fn set_directory_permissions(path: &Path) -> Result<(), ReleaseError> { 1431 use std::os::unix::fs::PermissionsExt as _; 1432 1433 fs::set_permissions(path, fs::Permissions::from_mode(0o755)) 1434 .map_err(|_| ReleaseError::Generation) 1435 } 1436 1437 #[cfg(not(unix))] 1438 fn set_directory_permissions(_path: &Path) -> Result<(), ReleaseError> { 1439 Ok(()) 1440 } 1441 1442 #[cfg(unix)] 1443 fn sync_directory(path: &Path) -> Result<(), ReleaseError> { 1444 fs::File::open(path) 1445 .and_then(|directory| directory.sync_all()) 1446 .map_err(|_| ReleaseError::Generation) 1447 } 1448 1449 #[cfg(not(unix))] 1450 fn sync_directory(_path: &Path) -> Result<(), ReleaseError> { 1451 Ok(()) 1452 } 1453 1454 #[cfg(unix)] 1455 fn publish_directory(source: &Path, destination: &Path) -> Result<(), ReleaseError> { 1456 use rustix::fs::{CWD, RenameFlags, renameat_with}; 1457 1458 renameat_with(CWD, source, CWD, destination, RenameFlags::NOREPLACE) 1459 .map_err(|_| ReleaseError::Generation) 1460 } 1461 1462 #[cfg(not(unix))] 1463 fn publish_directory(_source: &Path, _destination: &Path) -> Result<(), ReleaseError> { 1464 Err(ReleaseError::Generation) 1465 } 1466 1467 struct BoundedWriter<W> { 1468 inner: W, 1469 written: u64, 1470 maximum: u64, 1471 } 1472 1473 impl<W> BoundedWriter<W> { 1474 const fn new(inner: W, maximum: u64) -> Self { 1475 Self { 1476 inner, 1477 written: 0, 1478 maximum, 1479 } 1480 } 1481 } 1482 1483 impl BoundedWriter<fs::File> { 1484 fn sync_all(&self) -> std::io::Result<()> { 1485 self.inner.sync_all() 1486 } 1487 } 1488 1489 impl<W: std::io::Write> std::io::Write for BoundedWriter<W> { 1490 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { 1491 let remaining = self.maximum.saturating_sub(self.written); 1492 if remaining == 0 && !bytes.is_empty() { 1493 return Err(std::io::Error::other("bounded output exceeded")); 1494 } 1495 let admitted = bytes 1496 .len() 1497 .min(usize::try_from(remaining).unwrap_or(usize::MAX)); 1498 let written = self.inner.write(&bytes[..admitted])?; 1499 self.written = self 1500 .written 1501 .checked_add(u64::try_from(written).map_err(std::io::Error::other)?) 1502 .ok_or_else(|| std::io::Error::other("bounded output exceeded"))?; 1503 Ok(written) 1504 } 1505 1506 fn flush(&mut self) -> std::io::Result<()> { 1507 self.inner.flush() 1508 } 1509 } 1510 1511 fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ReleaseError> { 1512 let mut bytes = serde_json::to_vec(value).map_err(|_| ReleaseError::Generation)?; 1513 bytes.push(b'\n'); 1514 write_generated(path, &bytes) 1515 } 1516 1517 fn write_generated(path: &Path, bytes: &[u8]) -> Result<(), ReleaseError> { 1518 if bytes.is_empty() || bytes.len() as u64 > MAX_DOCUMENT_BYTES { 1519 return Err(ReleaseError::Generation); 1520 } 1521 scan_bytes(bytes)?; 1522 let mut file = create_new(path)?; 1523 file.write_all(bytes) 1524 .and_then(|()| file.sync_all()) 1525 .map_err(|_| ReleaseError::Generation) 1526 } 1527 1528 fn write_checksums(root: &Path) -> Result<(), ReleaseError> { 1529 let records = inventory_records(root)?; 1530 let mut output = String::new(); 1531 use fmt::Write as _; 1532 for record in records { 1533 writeln!(output, "{} {}", record.sha256, record.path) 1534 .map_err(|_| ReleaseError::Generation)?; 1535 } 1536 write_generated(&root.join("SHA256SUMS"), output.as_bytes()) 1537 } 1538 1539 fn inventory_records(root: &Path) -> Result<Vec<ArtifactRecord>, ReleaseError> { 1540 let mut names = fs::read_dir(root) 1541 .map_err(|_| ReleaseError::InvalidOutput)? 1542 .collect::<Result<Vec<_>, _>>() 1543 .map_err(|_| ReleaseError::InvalidOutput)?; 1544 names.sort_by_key(fs::DirEntry::file_name); 1545 names 1546 .into_iter() 1547 .map(|entry| { 1548 let name = entry 1549 .file_name() 1550 .into_string() 1551 .map_err(|_| ReleaseError::InvalidOutput)?; 1552 let evidence = hash_regular(&entry.path(), output_maximum(&name)?)?; 1553 Ok(ArtifactRecord { 1554 path: name, 1555 byte_length: evidence.byte_length, 1556 sha256: evidence.sha256, 1557 }) 1558 }) 1559 .collect() 1560 } 1561 1562 fn output_maximum(name: &str) -> Result<u64, ReleaseError> { 1563 match name { 1564 "binary.tar.gz" => Ok(MAX_BINARY_BYTES + MAX_TEXT_BYTES), 1565 "service-source.tar.gz" => Ok(MAX_SOURCE_ARCHIVE_BYTES), 1566 "LICENSE" 1567 | "config.example.toml" 1568 | "config.schema.json" 1569 | "systemd.service" 1570 | SOURCE_LOCK => Ok(MAX_TEXT_BYTES), 1571 "SHA256SUMS" 1572 | "THIRD-PARTY-NOTICES.txt" 1573 | "artifact-manifest.v1.json" 1574 | "provenance-input.v1.json" 1575 | "sbom.cdx.json" => Ok(MAX_DOCUMENT_BYTES), 1576 _ => Err(ReleaseError::InvalidOutput), 1577 } 1578 } 1579 1580 fn validate_exact_inventory(root: &Path) -> Result<(), ReleaseError> { 1581 let actual = inventory_records(root)?; 1582 if actual 1583 .iter() 1584 .map(|record| record.path.as_str()) 1585 .collect::<Vec<_>>() 1586 != OUTPUT_NAMES 1587 { 1588 return Err(ReleaseError::InvalidOutput); 1589 } 1590 validate_output_permissions(root)?; 1591 Ok(()) 1592 } 1593 1594 #[cfg(unix)] 1595 fn validate_output_permissions(root: &Path) -> Result<(), ReleaseError> { 1596 use std::os::unix::fs::PermissionsExt as _; 1597 1598 let root_metadata = fs::symlink_metadata(root).map_err(|_| ReleaseError::InvalidOutput)?; 1599 if root_metadata.file_type().is_symlink() 1600 || !root_metadata.is_dir() 1601 || root_metadata.permissions().mode() & 0o777 != 0o755 1602 { 1603 return Err(ReleaseError::InvalidOutput); 1604 } 1605 for name in OUTPUT_NAMES { 1606 let metadata = 1607 fs::symlink_metadata(root.join(name)).map_err(|_| ReleaseError::InvalidOutput)?; 1608 if metadata.file_type().is_symlink() 1609 || !metadata.is_file() 1610 || metadata.permissions().mode() & 0o777 != 0o644 1611 { 1612 return Err(ReleaseError::InvalidOutput); 1613 } 1614 } 1615 Ok(()) 1616 } 1617 1618 #[cfg(not(unix))] 1619 fn validate_output_permissions(_root: &Path) -> Result<(), ReleaseError> { 1620 Ok(()) 1621 } 1622 1623 fn compare_output(output: &Path, expected: &[ArtifactRecord]) -> Result<(), ReleaseError> { 1624 validate_exact_inventory(output)?; 1625 let actual = inventory_records(output)?; 1626 if actual != expected { 1627 return Err(ReleaseError::StaleOutput); 1628 } 1629 Ok(()) 1630 } 1631 1632 struct FileEvidence { 1633 byte_length: u64, 1634 sha256: String, 1635 } 1636 1637 fn hash_regular(path: &Path, maximum: u64) -> Result<FileEvidence, ReleaseError> { 1638 validate_regular(path, maximum, ReleaseError::InvalidOutput)?; 1639 let metadata = fs::metadata(path).map_err(|_| ReleaseError::InvalidOutput)?; 1640 let mut file = fs::File::open(path).map_err(|_| ReleaseError::InvalidOutput)?; 1641 let mut hasher = Sha256::new(); 1642 let mut total = 0_u64; 1643 let mut buffer = [0_u8; COPY_BUFFER_BYTES]; 1644 loop { 1645 let read = file 1646 .read(&mut buffer) 1647 .map_err(|_| ReleaseError::InvalidOutput)?; 1648 if read == 0 { 1649 break; 1650 } 1651 total = total 1652 .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidOutput)?) 1653 .ok_or(ReleaseError::InvalidOutput)?; 1654 if total > maximum { 1655 return Err(ReleaseError::InvalidOutput); 1656 } 1657 hasher.update(&buffer[..read]); 1658 } 1659 if total != metadata.len() { 1660 return Err(ReleaseError::InvalidOutput); 1661 } 1662 Ok(FileEvidence { 1663 byte_length: total, 1664 sha256: hex::encode(hasher.finalize()), 1665 }) 1666 } 1667 1668 fn validate_regular(path: &Path, maximum: u64, error: ReleaseError) -> Result<(), ReleaseError> { 1669 let metadata = fs::symlink_metadata(path).map_err(|_| error)?; 1670 if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum { 1671 return Err(error); 1672 } 1673 Ok(()) 1674 } 1675 1676 fn read_bounded(path: &Path, maximum: u64, error: ReleaseError) -> Result<Vec<u8>, ReleaseError> { 1677 validate_regular(path, maximum, error)?; 1678 let mut bytes = Vec::new(); 1679 fs::File::open(path) 1680 .map_err(|_| error)? 1681 .take(maximum.saturating_add(1)) 1682 .read_to_end(&mut bytes) 1683 .map_err(|_| error)?; 1684 if bytes.len() as u64 > maximum { 1685 return Err(error); 1686 } 1687 Ok(bytes) 1688 } 1689 1690 fn command_capture_bounded( 1691 command: &mut Command, 1692 maximum: u64, 1693 error: ReleaseError, 1694 ) -> Result<Vec<u8>, ReleaseError> { 1695 let file = NamedTempFile::new().map_err(|_| error)?; 1696 let stdout = file.reopen().map_err(|_| error)?; 1697 let status = command 1698 .stdin(Stdio::null()) 1699 .stdout(Stdio::from(stdout)) 1700 .stderr(Stdio::null()) 1701 .status() 1702 .map_err(|_| error)?; 1703 if !status.success() { 1704 return Err(error); 1705 } 1706 read_bounded(file.path(), maximum, error) 1707 } 1708 1709 fn git_capture(root: &Path, arguments: &[&str], maximum: usize) -> Result<Vec<u8>, ReleaseError> { 1710 command_capture_bounded( 1711 Command::new("git").args(arguments).current_dir(root), 1712 maximum as u64, 1713 ReleaseError::InvalidSource, 1714 ) 1715 } 1716 1717 fn exact_line(bytes: &[u8]) -> Option<&str> { 1718 let value = std::str::from_utf8(bytes).ok()?.strip_suffix('\n')?; 1719 (!value.is_empty() && !value.contains(['\n', '\r'])).then_some(value) 1720 } 1721 1722 fn lower_hex(value: &str, length: usize) -> bool { 1723 value.len() == length 1724 && value 1725 .bytes() 1726 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 1727 } 1728 1729 #[derive(Default)] 1730 struct SecretScanner { 1731 tail: Vec<u8>, 1732 } 1733 1734 impl SecretScanner { 1735 fn scan(&mut self, bytes: &[u8]) -> Result<(), ReleaseError> { 1736 let mut combined = Vec::with_capacity(self.tail.len() + bytes.len()); 1737 combined.extend_from_slice(&self.tail); 1738 combined.extend_from_slice(bytes); 1739 if SECRET_PATTERN_PARTS 1740 .iter() 1741 .any(|(first, second)| contains_joined_bytes(&combined, first, second)) 1742 { 1743 return Err(ReleaseError::ProtectedMaterial); 1744 } 1745 let retained = SECRET_PATTERN_PARTS 1746 .iter() 1747 .map(|(first, second)| first.len().saturating_add(second.len()).saturating_sub(1)) 1748 .max() 1749 .unwrap_or(0) 1750 .min(combined.len()); 1751 self.tail.clear(); 1752 self.tail 1753 .extend_from_slice(&combined[combined.len() - retained..]); 1754 Ok(()) 1755 } 1756 } 1757 1758 fn scan_bytes(bytes: &[u8]) -> Result<(), ReleaseError> { 1759 let mut scanner = SecretScanner::default(); 1760 scanner.scan(bytes) 1761 } 1762 1763 fn contains_joined_bytes(haystack: &[u8], first: &[u8], second: &[u8]) -> bool { 1764 let length = first.len().saturating_add(second.len()); 1765 length > 0 1766 && haystack.windows(length).any(|window| { 1767 window.get(..first.len()) == Some(first) && window.get(first.len()..) == Some(second) 1768 }) 1769 } 1770 1771 #[cfg(test)] 1772 mod tests { 1773 use super::*; 1774 1775 #[test] 1776 fn argument_parser_is_closed_and_bounded() { 1777 let args = parse_native_release_args(vec![ 1778 "--mode".into(), 1779 "check".into(), 1780 "--target".into(), 1781 "x86_64-unknown-linux-gnu".into(), 1782 "--binary".into(), 1783 "/tmp/rhi".into(), 1784 "--output".into(), 1785 "/tmp/release".into(), 1786 "--source-date-epoch".into(), 1787 "1".into(), 1788 ]) 1789 .expect("valid arguments"); 1790 assert_eq!(args.mode, Mode::Check); 1791 assert_eq!(args.source_date_epoch, 1); 1792 for mutation in [ 1793 vec!["--mode".into(), "write".into()], 1794 vec![ 1795 "--mode".into(), 1796 "write".into(), 1797 "--mode".into(), 1798 "check".into(), 1799 "--target".into(), 1800 "x86_64-unknown-linux-gnu".into(), 1801 "--binary".into(), 1802 "/tmp/rhi".into(), 1803 "--output".into(), 1804 "/tmp/release".into(), 1805 "--source-date-epoch".into(), 1806 "1".into(), 1807 ], 1808 vec![ 1809 "--mode".into(), 1810 "write".into(), 1811 "--target".into(), 1812 "x86_64-apple-darwin".into(), 1813 "--binary".into(), 1814 "/tmp/rhi".into(), 1815 "--output".into(), 1816 "/tmp/release".into(), 1817 "--source-date-epoch".into(), 1818 "1".into(), 1819 ], 1820 ] { 1821 assert_eq!( 1822 parse_native_release_args(mutation).expect_err("invalid arguments"), 1823 ReleaseError::InvalidArguments 1824 ); 1825 } 1826 } 1827 1828 #[test] 1829 fn secret_scanner_detects_split_patterns() { 1830 let mut scanner = SecretScanner::default(); 1831 scanner.scan(b"prefix github_").expect("prefix"); 1832 assert_eq!( 1833 scanner.scan(b"pat_value").expect_err("secret rejected"), 1834 ReleaseError::ProtectedMaterial 1835 ); 1836 } 1837 1838 #[test] 1839 fn source_scanner_bounds_files_and_detects_protected_material() { 1840 let directory = TempDir::new().expect("tempdir"); 1841 fs::write(directory.path().join("safe.rs"), b"fn safe() {}").expect("safe source"); 1842 scan_source_tree(directory.path()).expect("safe source tree"); 1843 fs::write( 1844 directory.path().join("protected.txt"), 1845 [b"github_".as_slice(), b"pat_value".as_slice()].concat(), 1846 ) 1847 .expect("protected fixture"); 1848 assert_eq!( 1849 scan_source_tree(directory.path()).expect_err("protected source rejected"), 1850 ReleaseError::ProtectedMaterial 1851 ); 1852 } 1853 1854 #[cfg(unix)] 1855 #[test] 1856 fn binary_archive_is_deterministic_and_contains_one_member() { 1857 use std::os::unix::fs::PermissionsExt as _; 1858 1859 let directory = TempDir::new().expect("tempdir"); 1860 let binary = directory.path().join("rhi"); 1861 let mut elf = [0_u8; 20]; 1862 elf[..8].copy_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); 1863 elf[16..18].copy_from_slice(&3_u16.to_le_bytes()); 1864 elf[18..20].copy_from_slice(&62_u16.to_le_bytes()); 1865 fs::write(&binary, elf).expect("binary"); 1866 fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).expect("binary mode"); 1867 let first = directory.path().join("first.tar.gz"); 1868 let second = directory.path().join("second.tar.gz"); 1869 create_binary_archive(&binary, &first, "x86_64-unknown-linux-gnu", 1) 1870 .expect("first archive"); 1871 create_binary_archive(&binary, &second, "x86_64-unknown-linux-gnu", 1) 1872 .expect("second archive"); 1873 assert_eq!( 1874 fs::read(first).expect("first"), 1875 fs::read(second).expect("second") 1876 ); 1877 assert_eq!( 1878 create_binary_archive( 1879 &binary, 1880 &directory.path().join("wrong-target.tar.gz"), 1881 "aarch64-unknown-linux-gnu", 1882 1, 1883 ) 1884 .expect_err("target mismatch"), 1885 ReleaseError::InvalidBinary 1886 ); 1887 } 1888 1889 #[cfg(unix)] 1890 #[test] 1891 fn generated_permissions_are_exact() { 1892 use std::os::unix::fs::PermissionsExt as _; 1893 1894 let parent = TempDir::new().expect("tempdir"); 1895 let directory = parent.path().join("release"); 1896 fs::create_dir(&directory).expect("directory"); 1897 set_directory_permissions(&directory).expect("directory mode"); 1898 let file = directory.join("artifact"); 1899 create_new(&file).expect("artifact"); 1900 assert_eq!( 1901 fs::metadata(directory) 1902 .expect("directory metadata") 1903 .permissions() 1904 .mode() 1905 & 0o777, 1906 0o755 1907 ); 1908 assert_eq!( 1909 fs::metadata(file) 1910 .expect("file metadata") 1911 .permissions() 1912 .mode() 1913 & 0o777, 1914 0o644 1915 ); 1916 } 1917 1918 #[test] 1919 fn compressed_outputs_are_bounded_before_allocation() { 1920 let mut writer = BoundedWriter::new(Vec::new(), 3); 1921 assert!(writer.write_all(b"abc").is_ok()); 1922 assert_eq!(writer.written, 3); 1923 assert!(writer.write_all(b"d").is_err()); 1924 } 1925 1926 #[test] 1927 fn sbom_uses_spdx_expressions_in_the_governed_field() { 1928 assert_eq!( 1929 serde_json::to_value(SbomLicenseChoice { 1930 expression: "MIT OR Apache-2.0".to_owned(), 1931 }) 1932 .expect("license choice"), 1933 serde_json::json!({"expression": "MIT OR Apache-2.0"}) 1934 ); 1935 } 1936 1937 #[test] 1938 fn sbom_component_references_are_path_free_and_checkout_independent() { 1939 let package_at_first_path = CargoPackage { 1940 id: "path+file:///private/first/rhi#0.1.0".to_owned(), 1941 name: "rhi".to_owned(), 1942 version: "0.1.0".to_owned(), 1943 source: None, 1944 checksum: None, 1945 license: Some("AGPL-3.0-or-later".to_owned()), 1946 }; 1947 let package_at_second_path = CargoPackage { 1948 id: "path+file:///different/checkout/rhi#0.1.0".to_owned(), 1949 ..package_at_first_path.clone() 1950 }; 1951 let first = 1952 stable_package_reference(&package_at_first_path, true).expect("first reference"); 1953 let second = 1954 stable_package_reference(&package_at_second_path, true).expect("second reference"); 1955 assert_eq!(first, second); 1956 assert!(first.starts_with("urn:radroots:cargo-component:sha256:")); 1957 assert!(!first.contains("private")); 1958 assert!(!first.contains("checkout")); 1959 } 1960 1961 #[test] 1962 fn relative_paths_reject_escape_and_absolute_values() { 1963 for rejected in ["", "../escape", "a/../../escape", "/absolute"] { 1964 assert_eq!( 1965 validate_relative(rejected).expect_err("path rejected"), 1966 ReleaseError::InvalidSource 1967 ); 1968 } 1969 validate_relative("contracts/config.json").expect("safe path"); 1970 } 1971 1972 #[test] 1973 fn error_surface_is_fixed_and_source_free() { 1974 for error in [ 1975 ReleaseError::InvalidArguments, 1976 ReleaseError::InvalidSource, 1977 ReleaseError::DirtySource, 1978 ReleaseError::InvalidBinary, 1979 ReleaseError::InvalidOutput, 1980 ReleaseError::InvalidMetadata, 1981 ReleaseError::InvalidSourceLock, 1982 ReleaseError::ProtectedMaterial, 1983 ReleaseError::StaleOutput, 1984 ReleaseError::Generation, 1985 ] { 1986 assert!(!error.code().is_empty()); 1987 let display = error.to_string(); 1988 assert!(!display.contains('/')); 1989 assert!(!display.contains("github_pat")); 1990 assert!(std::error::Error::source(&error).is_none()); 1991 } 1992 } 1993 }