service_release_artifacts.rs (191247B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 ffi::OsStr, 4 fmt, fs, 5 io::{Read as _, Write as _}, 6 path::{Component, Path, PathBuf}, 7 process::{Command, Stdio}, 8 }; 9 10 use flate2::{Compression, GzBuilder}; 11 use serde::{Deserialize, Serialize}; 12 use sha2::{Digest as _, Sha256}; 13 use tar::{Builder as TarBuilder, Header as TarHeader}; 14 use tempfile::TempDir; 15 16 use crate::safe_artifact_io::{TarGzipLimits, TraversalLimits}; 17 use crate::service_source_lock::{LIB_REPOSITORY, validate_deferred_nix_material}; 18 use crate::service_source_lock_v3::{ 19 LOCK_FILENAME, PREDECESSOR_LOCK_FILENAME, ServiceSourceLockV3, 20 }; 21 use crate::{artifact_admission, exact_tree_archive, safe_artifact_io}; 22 23 const CONTRACT_RELATIVE: &str = 24 "contracts/architecture/decisions/services_hardening_release_artifacts.v4.json"; 25 const INPUT_NAMES: [&str; 6] = [ 26 "config.example.toml", 27 "config.schema.json", 28 "nixos-module.nix", 29 "oci-image.tar.gz", 30 "service-binary", 31 "systemd.service", 32 ]; 33 const OUTPUT_NAMES: [&str; 20] = [ 34 "LICENSE-APACHE", 35 "LICENSE-MIT", 36 "SHA256SUMS", 37 "THIRD-PARTY-LICENSES.txt", 38 "THIRD-PARTY-NOTICES.txt", 39 "artifact-manifest.v2.json", 40 "artifact-scan.v1.json", 41 "binary.tar.gz", 42 "config.example.toml", 43 "config.schema.json", 44 "lib-source.tar", 45 "nixos-module.nix", 46 "oci-image.tar.gz", 47 "oci-image.v1.json", 48 "provenance.intoto.jsonl", 49 "radroots.service.source-lock.v3.toml", 50 "sbom.cdx.json", 51 "service-source.tar", 52 "source-archives.v3.json", 53 "systemd.service", 54 ]; 55 const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]; 56 const SECRET_PATTERNS: [&[u8]; 7] = [ 57 b"-----BEGIN PRIVATE KEY-----", 58 b"-----BEGIN RSA PRIVATE KEY-----", 59 b"-----BEGIN EC PRIVATE KEY-----", 60 b"-----BEGIN OPENSSH PRIVATE KEY-----", 61 b"github_pat_", 62 b"ghp_", 63 b"xoxb-", 64 ]; 65 const MAX_CONTRACT_BYTES: usize = 65_536; 66 const MAX_MANIFEST_BYTES: usize = 1_048_576; 67 const MAX_TEXT_INPUT_BYTES: u64 = 1_048_576; 68 const MAX_GENERATED_DOCUMENT_BYTES: u64 = 16_777_216; 69 const MAX_SOURCE_LOCK_BYTES: u64 = 4_096; 70 const MAX_SERVICE_CARGO_LOCK_BYTES: u64 = 16_777_216; 71 const MAX_SERVICE_FLAKE_LOCK_BYTES: u64 = 4_194_304; 72 const MAX_BINARY_BYTES: u64 = 536_870_912; 73 const MAX_SOURCE_ARCHIVE_BYTES: u64 = 1_073_741_824; 74 const MAX_SOURCE_ARCHIVE_MEMBER_BYTES: u64 = 67_108_864; 75 const MAX_SOURCE_ARCHIVE_MEMBERS: u64 = 65_536; 76 const MAX_OCI_BYTES: u64 = 2_147_483_648; 77 const MAX_METADATA_BYTES: usize = 33_554_432; 78 const MAX_GIT_OUTPUT_BYTES: usize = 65_536; 79 const MAX_PACKAGES: usize = 8_192; 80 const MAX_WORKSPACE_PACKAGES: usize = 64; 81 const MAX_TEXT_FIELD_BYTES: usize = 512; 82 const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; 83 const MAX_ARCHIVE_PATH_BYTES: usize = 4_096; 84 const MAX_RELEASE_TREE_BYTES: u64 = 68_719_476_736; 85 const FILE_MODE: u32 = 0o644; 86 const DIRECTORY_MODE: u32 = 0o755; 87 const CANDIDATE_DIGEST_DOMAIN: &str = "sha256:"; 88 const SECRET_SCAN_OVERLAP_BYTES: usize = 131_072; 89 90 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 91 pub(crate) enum CommandMode { 92 Check, 93 Write, 94 } 95 96 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 97 enum ReleaseArtifactError { 98 InvalidContract, 99 InvalidServiceRoot, 100 DirtyServiceSource, 101 InvalidServiceMetadata, 102 InvalidInputRoot, 103 InvalidInputArtifact, 104 InvalidSourceLock, 105 InvalidSourceBundle, 106 InvalidPackageInventory, 107 ProtectedMaterialDetected, 108 InvalidOutputRoot, 109 StaleOutput, 110 GenerationFailure, 111 } 112 113 impl ReleaseArtifactError { 114 const fn code(self) -> &'static str { 115 match self { 116 Self::InvalidContract => "invalid_contract", 117 Self::InvalidServiceRoot => "invalid_service_root", 118 Self::DirtyServiceSource => "dirty_service_source", 119 Self::InvalidServiceMetadata => "invalid_service_metadata", 120 Self::InvalidInputRoot => "invalid_input_root", 121 Self::InvalidInputArtifact => "invalid_input_artifact", 122 Self::InvalidSourceLock => "invalid_source_lock", 123 Self::InvalidSourceBundle => "invalid_source_bundle", 124 Self::InvalidPackageInventory => "invalid_package_inventory", 125 Self::ProtectedMaterialDetected => "protected_material_detected", 126 Self::InvalidOutputRoot => "invalid_output_root", 127 Self::StaleOutput => "stale_output", 128 Self::GenerationFailure => "generation_failure", 129 } 130 } 131 } 132 133 impl fmt::Display for ReleaseArtifactError { 134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 135 formatter.write_str(match self { 136 Self::InvalidContract => "service release artifact contract is invalid", 137 Self::InvalidServiceRoot => "service release source root is invalid", 138 Self::DirtyServiceSource => "service release source contains an ungoverned change", 139 Self::InvalidServiceMetadata => "service release metadata is invalid", 140 Self::InvalidInputRoot => "service release input root is invalid", 141 Self::InvalidInputArtifact => "service release input artifact is invalid", 142 Self::InvalidSourceLock => "service release source lock is invalid", 143 Self::InvalidSourceBundle => "service release source bundle is invalid", 144 Self::InvalidPackageInventory => "service release package inventory is invalid", 145 Self::ProtectedMaterialDetected => "service release input contains protected material", 146 Self::InvalidOutputRoot => "service release output root is invalid", 147 Self::StaleOutput => "service release artifact set is stale", 148 Self::GenerationFailure => "service release artifacts could not be generated", 149 }) 150 } 151 } 152 153 impl std::error::Error for ReleaseArtifactError {} 154 155 #[derive(Debug, Deserialize)] 156 #[serde(deny_unknown_fields)] 157 struct ReleaseMetadata { 158 service: String, 159 service_package: String, 160 binary_name: String, 161 version: String, 162 #[serde(skip)] 163 license: String, 164 } 165 166 #[derive(Debug, Deserialize)] 167 struct CargoMetadata { 168 packages: Vec<CargoPackage>, 169 workspace_members: Vec<String>, 170 resolve: Option<CargoResolve>, 171 } 172 173 #[derive(Debug, Deserialize)] 174 struct CargoPackage { 175 id: String, 176 name: String, 177 version: String, 178 source: Option<String>, 179 checksum: Option<String>, 180 license: Option<String>, 181 manifest_path: String, 182 license_file: Option<String>, 183 targets: Vec<CargoTarget>, 184 #[serde(skip)] 185 license_texts: Vec<DependencyLicenseText>, 186 } 187 188 #[derive(Clone, Debug)] 189 struct DependencyLicenseText { 190 filename: String, 191 sha256: String, 192 text: String, 193 } 194 195 #[derive(Debug, Deserialize)] 196 struct CargoTarget { 197 name: String, 198 kind: Vec<String>, 199 } 200 201 #[derive(Debug, Deserialize)] 202 struct CargoResolve { 203 nodes: Vec<CargoNode>, 204 } 205 206 #[derive(Debug, Deserialize)] 207 struct CargoNode { 208 id: String, 209 dependencies: Vec<String>, 210 } 211 212 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 213 struct DigestValue { 214 alg: &'static str, 215 content: String, 216 } 217 218 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 219 struct LicenseChoice { 220 expression: String, 221 } 222 223 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 224 struct SbomComponent { 225 #[serde(rename = "type")] 226 component_type: &'static str, 227 #[serde(rename = "bom-ref")] 228 bom_ref: String, 229 name: String, 230 version: String, 231 #[serde(skip_serializing_if = "Option::is_none")] 232 purl: Option<String>, 233 licenses: Vec<LicenseChoice>, 234 #[serde(skip_serializing_if = "Vec::is_empty")] 235 hashes: Vec<DigestValue>, 236 #[serde(skip_serializing_if = "Vec::is_empty")] 237 properties: Vec<SbomProperty>, 238 } 239 240 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 241 struct SbomProperty { 242 name: String, 243 value: String, 244 } 245 246 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] 247 struct SbomDependency { 248 #[serde(rename = "ref")] 249 reference: String, 250 depends_on: Vec<String>, 251 } 252 253 #[derive(Debug, Serialize)] 254 struct SbomMetadata { 255 component: SbomComponent, 256 properties: Vec<SbomProperty>, 257 } 258 259 #[derive(Debug, Serialize)] 260 struct SbomComposition { 261 aggregate: &'static str, 262 assemblies: Vec<String>, 263 dependencies: Vec<String>, 264 } 265 266 #[derive(Debug, Serialize)] 267 struct CycloneDxSbom { 268 #[serde(rename = "$schema")] 269 json_schema: &'static str, 270 #[serde(rename = "bomFormat")] 271 bom_format: &'static str, 272 #[serde(rename = "specVersion")] 273 spec_version: &'static str, 274 version: u32, 275 metadata: SbomMetadata, 276 components: Vec<SbomComponent>, 277 dependencies: Vec<SbomDependency>, 278 compositions: Vec<SbomComposition>, 279 } 280 281 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 282 struct ArtifactRecord { 283 path: String, 284 byte_length: u64, 285 sha256: String, 286 } 287 288 #[derive(Debug, Serialize)] 289 struct ContractVersionsDocument { 290 config: u32, 291 state: u32, 292 admin: u32, 293 status: u32, 294 provider: u32, 295 } 296 297 #[derive(Debug, Serialize)] 298 struct SourceArchiveDocument { 299 schema: &'static str, 300 contract_version: u32, 301 candidate_digest: String, 302 service: String, 303 service_revision: String, 304 lib_repository: &'static str, 305 lib_revision: String, 306 service_source: ArtifactRecord, 307 lib_source: ArtifactRecord, 308 source_lock_sha256: String, 309 workspace_catalog_sha256: String, 310 cargo_lock_sha256: String, 311 flake_lock_sha256: String, 312 format: &'static str, 313 compression: &'static str, 314 git_history: &'static str, 315 } 316 317 #[derive(Debug, Serialize)] 318 struct OciImageDocument { 319 schema: &'static str, 320 contract_version: u32, 321 service: String, 322 version: String, 323 target: String, 324 image: ArtifactRecord, 325 } 326 327 #[derive(Debug, Serialize)] 328 struct ArtifactManifestDocument { 329 schema: &'static str, 330 contract_version: u32, 331 candidate_digest: String, 332 service: String, 333 version: String, 334 target: String, 335 source_date_epoch: u32, 336 service_revision: String, 337 lib_revision: String, 338 rust_version: &'static str, 339 host_feature_profile: &'static str, 340 contract_versions: ContractVersionsDocument, 341 confidentiality: ConfidentialityDocument, 342 artifacts: Vec<ArtifactRecord>, 343 } 344 345 #[derive(Debug, Serialize)] 346 struct ConfidentialityDocument { 347 state: &'static str, 348 derived_from: ArtifactRecord, 349 protected_material_included: bool, 350 } 351 352 #[derive(Debug, Serialize)] 353 struct ScanDocument { 354 schema: &'static str, 355 contract_version: u32, 356 candidate_digest: String, 357 state: &'static str, 358 ruleset_sha256: String, 359 scanned_artifacts: Vec<ArtifactRecord>, 360 nested_members_scanned: u64, 361 expanded_bytes_scanned: u64, 362 } 363 364 #[derive(Debug, Serialize)] 365 struct InTotoStatement { 366 #[serde(rename = "_type")] 367 statement_type: &'static str, 368 subject: Vec<InTotoSubject>, 369 #[serde(rename = "predicateType")] 370 predicate_type: &'static str, 371 predicate: SlsaPredicate, 372 } 373 374 #[derive(Debug, Serialize)] 375 struct InTotoSubject { 376 name: String, 377 digest: BTreeMap<&'static str, String>, 378 } 379 380 #[derive(Debug, Serialize)] 381 struct SlsaPredicate { 382 #[serde(rename = "buildDefinition")] 383 build_definition: SlsaBuildDefinition, 384 #[serde(rename = "runDetails")] 385 run_details: SlsaRunDetails, 386 } 387 388 #[derive(Debug, Serialize)] 389 struct SlsaBuildDefinition { 390 #[serde(rename = "buildType")] 391 build_type: &'static str, 392 #[serde(rename = "externalParameters")] 393 external_parameters: SlsaExternalParameters, 394 #[serde(rename = "internalParameters")] 395 internal_parameters: BTreeMap<String, String>, 396 #[serde(rename = "resolvedDependencies")] 397 resolved_dependencies: Vec<SlsaResolvedDependency>, 398 } 399 400 #[derive(Debug, Serialize)] 401 struct SlsaExternalParameters { 402 candidate_digest: String, 403 service: String, 404 target: String, 405 source_date_epoch: u32, 406 } 407 408 #[derive(Debug, Serialize)] 409 struct SlsaResolvedDependency { 410 uri: String, 411 digest: BTreeMap<&'static str, String>, 412 } 413 414 #[derive(Debug, Serialize)] 415 struct SlsaRunDetails { 416 builder: SlsaBuilder, 417 metadata: SlsaRunMetadata, 418 } 419 420 #[derive(Debug, Serialize)] 421 struct SlsaBuilder { 422 id: &'static str, 423 } 424 425 #[derive(Debug, Serialize)] 426 struct SlsaRunMetadata { 427 #[serde(rename = "invocationId")] 428 invocation_id: String, 429 } 430 431 #[derive(Clone, Debug)] 432 struct FileEvidence { 433 byte_length: u64, 434 sha256: String, 435 } 436 437 #[derive(Debug, Deserialize)] 438 #[serde(deny_unknown_fields)] 439 struct ReleaseDecision { 440 schema: String, 441 contract_version: u32, 442 decision_state: String, 443 owner_step: u32, 444 predecessor: ReleasePredecessor, 445 command: String, 446 modes: Vec<String>, 447 required_arguments: Vec<String>, 448 service_metadata_path: String, 449 service_metadata_fields: Vec<String>, 450 service_license_path: String, 451 source_lock_schema: String, 452 source_lock_definition: String, 453 artifact_contract_binding: String, 454 artifact_admission_contract: String, 455 supported_targets: Vec<String>, 456 candidate_binding: String, 457 lib_root_binding: String, 458 binary_admission: String, 459 oci_admission: String, 460 input_inventory: Vec<String>, 461 excluded_parent_owned_inputs: Vec<String>, 462 service_root_inventory: Vec<String>, 463 output_inventory: Vec<String>, 464 canonical_json: String, 465 checksum_format: String, 466 source_archive_format: String, 467 sbom_format: String, 468 license_evidence: String, 469 provenance_posture: String, 470 protected_material_scan_scope: String, 471 confidentiality_state: String, 472 source_cleanliness: String, 473 revision_stability: String, 474 no_protected_material: bool, 475 maximums: ReleaseMaximums, 476 required_negative_vectors: Vec<String>, 477 negative_error_codes: Vec<String>, 478 } 479 480 #[derive(Debug, Deserialize)] 481 #[serde(deny_unknown_fields)] 482 struct ReleasePredecessor { 483 schema: String, 484 filename: String, 485 transition: String, 486 } 487 488 #[derive(Debug, Deserialize)] 489 #[serde(deny_unknown_fields)] 490 struct ReleaseMaximums { 491 text_input_bytes: u64, 492 generated_document_bytes: u64, 493 service_cargo_lock_bytes: u64, 494 service_flake_lock_bytes: u64, 495 binary_bytes: u64, 496 source_archive_bytes: u64, 497 source_archive_member_bytes: u64, 498 source_archive_members: u64, 499 oci_bytes: u64, 500 artifact_scan_expanded_bytes: u64, 501 cargo_metadata_bytes: usize, 502 packages: usize, 503 workspace_packages: usize, 504 } 505 506 #[derive(Clone, Copy)] 507 pub(crate) struct Arguments<'a> { 508 pub(crate) mode: CommandMode, 509 pub(crate) service_root: &'a Path, 510 pub(crate) lib_root: &'a Path, 511 pub(crate) input_root: &'a Path, 512 pub(crate) output_root: &'a Path, 513 pub(crate) target: &'a str, 514 pub(crate) source_date_epoch: u32, 515 pub(crate) candidate_digest: &'a str, 516 } 517 518 pub(crate) fn run(arguments: Arguments<'_>) -> Result<(), String> { 519 run_inner(arguments).map_err(|error| error.to_string()) 520 } 521 522 fn run_inner(arguments: Arguments<'_>) -> Result<(), ReleaseArtifactError> { 523 let Arguments { 524 mode, 525 service_root, 526 lib_root, 527 input_root, 528 output_root, 529 target, 530 source_date_epoch, 531 candidate_digest, 532 } = arguments; 533 if !SUPPORTED_TARGETS.contains(&target) 534 || source_date_epoch == 0 535 || !valid_lower_hex(candidate_digest, 64) 536 { 537 return Err(ReleaseArtifactError::InvalidServiceMetadata); 538 } 539 let service_root = validate_git_root(service_root)?; 540 let lib_root = validate_git_root(lib_root)?; 541 if git_remote(&lib_root)? != "ssh://git@github.com/radrootslabs/lib.git" 542 && git_remote(&lib_root)? != LIB_REPOSITORY 543 { 544 return Err(ReleaseArtifactError::InvalidServiceRoot); 545 } 546 let (input_root, input_snapshot) = validate_exact_input_root(input_root)?; 547 let (output_parent, output_root) = 548 validate_output_parent(output_root, &service_root, &input_root)?; 549 let initial_head = git_head(&service_root)?; 550 validate_clean_git(&service_root)?; 551 552 let metadata = read_release_metadata(&service_root)?; 553 let source_lock_bytes = read_bounded_regular( 554 &service_root.join(LOCK_FILENAME), 555 MAX_SOURCE_LOCK_BYTES, 556 ReleaseArtifactError::InvalidSourceLock, 557 )?; 558 let source_lock = ServiceSourceLockV3::from_canonical_bytes(&source_lock_bytes) 559 .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; 560 if source_lock.service() != metadata.service { 561 return Err(ReleaseArtifactError::InvalidSourceLock); 562 } 563 validate_source_lock_files(&service_root, &source_lock)?; 564 let cargo_metadata = cargo_metadata(&service_root)?; 565 let (sbom, notices, license_texts) = build_supply_chain_documents(&metadata, cargo_metadata)?; 566 567 let staging = tempfile::Builder::new() 568 .prefix(".radroots-service-release-") 569 .tempdir_in(&output_parent) 570 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 571 set_directory_mode(staging.path())?; 572 573 let static_inputs = [ 574 ("config.example.toml", "config.example.toml"), 575 ("config.schema.json", "config.schema.json"), 576 ("systemd.service", "systemd.service"), 577 ("nixos-module.nix", "nixos-module.nix"), 578 ]; 579 for (input, output) in static_inputs { 580 copy_snapshot_file( 581 &input_snapshot, 582 input, 583 &staging.path().join(output), 584 MAX_TEXT_INPUT_BYTES, 585 )?; 586 validate_text_artifact(&staging.path().join(output), output)?; 587 } 588 copy_bounded( 589 &service_root.join("LICENSE-APACHE"), 590 &staging.path().join("LICENSE-APACHE"), 591 MAX_TEXT_INPUT_BYTES, 592 )?; 593 validate_text_artifact(&staging.path().join("LICENSE-APACHE"), "LICENSE-APACHE")?; 594 copy_bounded( 595 &service_root.join("LICENSE-MIT"), 596 &staging.path().join("LICENSE-MIT"), 597 MAX_TEXT_INPUT_BYTES, 598 )?; 599 validate_text_artifact(&staging.path().join("LICENSE-MIT"), "LICENSE-MIT")?; 600 write_generated(&staging.path().join(LOCK_FILENAME), &source_lock_bytes)?; 601 create_binary_archive_from_snapshot( 602 &input_snapshot, 603 "service-binary", 604 &staging.path().join("binary.tar.gz"), 605 &metadata.binary_name, 606 target, 607 source_date_epoch, 608 )?; 609 let oci = copy_snapshot_file( 610 &input_snapshot, 611 "oci-image.tar.gz", 612 &staging.path().join("oci-image.tar.gz"), 613 MAX_OCI_BYTES, 614 )?; 615 let versions = source_lock.contract_versions(); 616 artifact_admission::admit_oci( 617 &staging.path().join("oci-image.tar.gz"), 618 staging.path(), 619 &artifact_admission::OciExpectation { 620 service: &metadata.service, 621 binary_name: &metadata.binary_name, 622 version: &metadata.version, 623 service_revision: &initial_head, 624 lib_revision: source_lock.revision(), 625 license: &metadata.license, 626 contract_versions: artifact_admission::ContractVersions { 627 admin: versions.admin(), 628 config: versions.config(), 629 provider: versions.provider(), 630 state: versions.state(), 631 status: versions.status(), 632 }, 633 }, 634 ) 635 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 636 input_snapshot 637 .revalidate() 638 .map_err(|_| ReleaseArtifactError::InvalidInputRoot)?; 639 let service_source = exact_tree_archive::create( 640 &service_root, 641 &initial_head, 642 &staging.path().join("service-source.tar"), 643 u64::from(source_date_epoch), 644 ) 645 .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; 646 let lib_mtime = exact_tree_archive::commit_timestamp(&lib_root, source_lock.revision()) 647 .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; 648 let lib_source = exact_tree_archive::create( 649 &lib_root, 650 source_lock.revision(), 651 &staging.path().join("lib-source.tar"), 652 lib_mtime, 653 ) 654 .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; 655 if lib_source.sha256 != source_lock.source_archive_sha256() { 656 return Err(ReleaseArtifactError::InvalidSourceBundle); 657 } 658 let catalog = exact_tree_archive::read_blob( 659 &lib_root, 660 source_lock.revision(), 661 "contracts/crates/catalog.v2.toml", 662 MAX_TEXT_INPUT_BYTES as usize, 663 ) 664 .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; 665 if sha256_bytes(&catalog) != source_lock.workspace_catalog_sha256() { 666 return Err(ReleaseArtifactError::InvalidSourceBundle); 667 } 668 669 let oci_document = OciImageDocument { 670 schema: "radroots.service.oci-image.v1", 671 contract_version: 1, 672 service: metadata.service.clone(), 673 version: metadata.version.clone(), 674 target: target.to_owned(), 675 image: artifact_record("oci-image.tar.gz", &oci), 676 }; 677 write_json(&staging.path().join("oci-image.v1.json"), &oci_document)?; 678 let source_lock_sha256 = sha256_bytes(&source_lock_bytes); 679 let source_document = SourceArchiveDocument { 680 schema: "radroots.service.source-archives.v3", 681 contract_version: 3, 682 candidate_digest: candidate_digest.to_owned(), 683 service: metadata.service.clone(), 684 service_revision: initial_head.clone(), 685 lib_repository: LIB_REPOSITORY, 686 lib_revision: source_lock.revision().to_owned(), 687 service_source: artifact_record_from_exact_tree("service-source.tar", &service_source), 688 lib_source: artifact_record_from_exact_tree("lib-source.tar", &lib_source), 689 source_lock_sha256: source_lock_sha256.clone(), 690 workspace_catalog_sha256: source_lock.workspace_catalog_sha256().to_owned(), 691 cargo_lock_sha256: source_lock.cargo_lock_sha256().to_owned(), 692 flake_lock_sha256: source_lock.flake_lock_sha256().to_owned(), 693 format: "ustar", 694 compression: "none", 695 git_history: "forbidden", 696 }; 697 write_json( 698 &staging.path().join("source-archives.v3.json"), 699 &source_document, 700 )?; 701 write_generated( 702 &staging.path().join("THIRD-PARTY-NOTICES.txt"), 703 notices.as_bytes(), 704 )?; 705 write_generated( 706 &staging.path().join("THIRD-PARTY-LICENSES.txt"), 707 license_texts.as_bytes(), 708 )?; 709 710 let mut sbom = sbom; 711 let sbom_artifacts = inventory_records(staging.path())?; 712 reconcile_sbom_artifacts(&mut sbom, &sbom_artifacts); 713 validate_cyclonedx_profile(&sbom, &sbom_artifacts)?; 714 write_json(&staging.path().join("sbom.cdx.json"), &sbom)?; 715 let scanned_artifacts = inventory_records(staging.path())?; 716 let (nested_members_scanned, expanded_bytes_scanned) = 717 scan_artifact_inventory(staging.path(), &scanned_artifacts)?; 718 let scan = ScanDocument { 719 schema: "radroots.service.artifact-scan.v1", 720 contract_version: 1, 721 candidate_digest: candidate_digest.to_owned(), 722 state: "no_protected_material_detected", 723 ruleset_sha256: scanner_ruleset_sha256(), 724 scanned_artifacts, 725 nested_members_scanned, 726 expanded_bytes_scanned, 727 }; 728 write_json(&staging.path().join("artifact-scan.v1.json"), &scan)?; 729 let scan_evidence = hash_regular( 730 &staging.path().join("artifact-scan.v1.json"), 731 MAX_GENERATED_DOCUMENT_BYTES, 732 )?; 733 734 let payload = inventory_records(staging.path())?; 735 let versions = source_lock.contract_versions(); 736 let manifest = ArtifactManifestDocument { 737 schema: "radroots.service.release-artifacts.v2", 738 contract_version: 2, 739 candidate_digest: candidate_digest.to_owned(), 740 service: metadata.service.clone(), 741 version: metadata.version.clone(), 742 target: target.to_owned(), 743 source_date_epoch, 744 service_revision: initial_head.clone(), 745 lib_revision: source_lock.revision().to_owned(), 746 rust_version: "1.97.1", 747 host_feature_profile: "service-host", 748 contract_versions: ContractVersionsDocument { 749 config: versions.config(), 750 state: versions.state(), 751 admin: versions.admin(), 752 status: versions.status(), 753 provider: versions.provider(), 754 }, 755 confidentiality: ConfidentialityDocument { 756 state: scan.state, 757 derived_from: artifact_record("artifact-scan.v1.json", &scan_evidence), 758 protected_material_included: false, 759 }, 760 artifacts: payload.clone(), 761 }; 762 validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &payload)?; 763 write_json(&staging.path().join("artifact-manifest.v2.json"), &manifest)?; 764 let manifest_evidence = hash_regular( 765 &staging.path().join("artifact-manifest.v2.json"), 766 MAX_TEXT_INPUT_BYTES, 767 )?; 768 let service_repository = git_remote(&service_root)?; 769 let provenance = build_provenance( 770 ProvenanceInput { 771 candidate_digest, 772 service: &metadata.service, 773 target, 774 source_date_epoch, 775 service_repository: &service_repository, 776 service_revision: &initial_head, 777 lib_revision: source_lock.revision(), 778 source_lock_sha256: &source_lock_sha256, 779 manifest_sha256: &manifest_evidence.sha256, 780 }, 781 &payload, 782 ); 783 validate_provenance_subjects(&provenance, candidate_digest, &payload)?; 784 write_json(&staging.path().join("provenance.intoto.jsonl"), &provenance)?; 785 write_checksums(staging.path())?; 786 validate_exact_output_inventory(staging.path())?; 787 sync_directory(staging.path())?; 788 let expected_output = inventory_records(staging.path())?; 789 790 validate_clean_git(&service_root)?; 791 if git_head(&service_root)? != initial_head { 792 return Err(ReleaseArtifactError::DirtyServiceSource); 793 } 794 795 if output_root.exists() { 796 compare_output(staging.path(), &output_root)?; 797 return Ok(()); 798 } 799 if mode == CommandMode::Check { 800 return Err(ReleaseArtifactError::StaleOutput); 801 } 802 let staging_path = staging.keep(); 803 fs::rename(&staging_path, &output_root).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 804 sync_directory(&output_parent)?; 805 validate_output_records(&output_root, &expected_output) 806 } 807 808 fn validate_confidentiality_binding( 809 manifest: &ArtifactManifestDocument, 810 scan: &ScanDocument, 811 scan_evidence: &FileEvidence, 812 payload: &[ArtifactRecord], 813 ) -> Result<(), ReleaseArtifactError> { 814 if manifest.candidate_digest != scan.candidate_digest 815 || manifest.confidentiality.state != scan.state 816 || manifest.confidentiality.protected_material_included 817 || manifest.confidentiality.derived_from.path != "artifact-scan.v1.json" 818 || manifest.confidentiality.derived_from.sha256 != scan_evidence.sha256 819 || manifest.artifacts != payload 820 { 821 return Err(ReleaseArtifactError::GenerationFailure); 822 } 823 Ok(()) 824 } 825 826 fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifactError> { 827 let bytes = read_bounded_regular( 828 &root.join("Cargo.toml"), 829 MAX_MANIFEST_BYTES as u64, 830 ReleaseArtifactError::InvalidServiceMetadata, 831 )?; 832 let text = 833 std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; 834 let value = toml::from_str::<toml::Value>(text) 835 .map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; 836 let release = value 837 .get("workspace") 838 .and_then(|value| value.get("metadata")) 839 .and_then(|value| value.get("radroots")) 840 .and_then(|value| value.get("service_release")) 841 .cloned() 842 .ok_or(ReleaseArtifactError::InvalidServiceMetadata)?; 843 let mut metadata = release 844 .try_into::<ReleaseMetadata>() 845 .map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; 846 metadata.license = value 847 .get("workspace") 848 .and_then(|workspace| workspace.get("package")) 849 .or_else(|| value.get("package")) 850 .and_then(|package| package.get("license")) 851 .and_then(toml::Value::as_str) 852 .ok_or(ReleaseArtifactError::InvalidServiceMetadata)? 853 .to_owned(); 854 if !valid_snake_identifier(&metadata.service) 855 || !valid_kebab_identifier(&metadata.service_package) 856 || !valid_kebab_identifier(&metadata.binary_name) 857 || metadata.version.len() > 128 858 || metadata.license != "AGPL-3.0-or-later" 859 || !matches!( 860 semver::Version::parse(&metadata.version), 861 Ok(version) if version.to_string() == metadata.version 862 ) 863 { 864 return Err(ReleaseArtifactError::InvalidServiceMetadata); 865 } 866 Ok(metadata) 867 } 868 869 fn validate_source_lock_files( 870 root: &Path, 871 source_lock: &ServiceSourceLockV3, 872 ) -> Result<(), ReleaseArtifactError> { 873 for name in [ 874 PREDECESSOR_LOCK_FILENAME, 875 "radroots.service.source-lock.v1.toml", 876 ] { 877 match fs::symlink_metadata(root.join(name)) { 878 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 879 _ => return Err(ReleaseArtifactError::InvalidSourceLock), 880 } 881 } 882 let cargo_lock = hash_regular(&root.join("Cargo.lock"), MAX_SERVICE_CARGO_LOCK_BYTES) 883 .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; 884 if cargo_lock.sha256 != source_lock.cargo_lock_sha256() { 885 return Err(ReleaseArtifactError::InvalidSourceLock); 886 } 887 let flake_nix = read_bounded_regular( 888 &root.join("flake.nix"), 889 MAX_TEXT_INPUT_BYTES, 890 ReleaseArtifactError::InvalidSourceLock, 891 )?; 892 let flake_lock = read_bounded_regular( 893 &root.join("flake.lock"), 894 MAX_SERVICE_FLAKE_LOCK_BYTES, 895 ReleaseArtifactError::InvalidSourceLock, 896 )?; 897 let evidence = validate_deferred_nix_material(&flake_nix, &flake_lock) 898 .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; 899 let artifact_contract = hash_regular( 900 &root.join(source_lock.artifact_contract_path()), 901 MAX_TEXT_INPUT_BYTES, 902 ) 903 .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; 904 if evidence.lib_revision() == source_lock.revision() 905 && evidence.flake_lock_sha256() == source_lock.flake_lock_sha256() 906 && artifact_contract.sha256 == source_lock.artifact_contract_sha256() 907 { 908 Ok(()) 909 } else { 910 Err(ReleaseArtifactError::InvalidSourceLock) 911 } 912 } 913 914 fn cargo_metadata(root: &Path) -> Result<CargoMetadata, ReleaseArtifactError> { 915 let mut command = Command::new("cargo"); 916 command 917 .args(["metadata", "--format-version", "1", "--locked", "--offline"]) 918 .current_dir(root); 919 let bytes = command_stdout(&mut command, MAX_METADATA_BYTES) 920 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 921 let mut metadata = serde_json::from_slice::<CargoMetadata>(&bytes) 922 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 923 for package in &mut metadata.packages { 924 if package.source.is_some() { 925 package.license_texts = dependency_license_texts(package)?; 926 } 927 } 928 Ok(metadata) 929 } 930 931 fn dependency_license_texts( 932 package: &CargoPackage, 933 ) -> Result<Vec<DependencyLicenseText>, ReleaseArtifactError> { 934 let manifest = Path::new(&package.manifest_path); 935 if !manifest.is_absolute() || manifest.file_name() != Some(OsStr::new("Cargo.toml")) { 936 return Err(ReleaseArtifactError::InvalidPackageInventory); 937 } 938 let package_root = manifest 939 .parent() 940 .ok_or(ReleaseArtifactError::InvalidPackageInventory)? 941 .canonicalize() 942 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 943 let mut candidates = BTreeSet::new(); 944 if let Some(license_file) = package.license_file.as_deref() { 945 let relative = Path::new(license_file); 946 if relative.is_absolute() 947 || relative 948 .components() 949 .any(|component| !matches!(component, Component::Normal(_))) 950 { 951 return Err(ReleaseArtifactError::InvalidPackageInventory); 952 } 953 candidates.insert(relative.to_path_buf()); 954 } else { 955 let entries = fs::read_dir(&package_root) 956 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 957 for (index, entry) in entries.enumerate() { 958 if index >= 256 { 959 return Err(ReleaseArtifactError::InvalidPackageInventory); 960 } 961 let entry = entry.map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 962 let filename = entry 963 .file_name() 964 .into_string() 965 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 966 let uppercase = filename.to_ascii_uppercase(); 967 if ["LICENSE", "COPYING", "COPYRIGHT", "NOTICE"] 968 .iter() 969 .any(|prefix| { 970 uppercase == *prefix 971 || uppercase.starts_with(&format!("{prefix}-")) 972 || uppercase.starts_with(&format!("{prefix}.")) 973 }) 974 { 975 candidates.insert(PathBuf::from(filename)); 976 } 977 } 978 } 979 if candidates.is_empty() || candidates.len() > 16 { 980 return Err(ReleaseArtifactError::InvalidPackageInventory); 981 } 982 let mut texts = Vec::with_capacity(candidates.len()); 983 let mut total = 0_u64; 984 for relative in candidates { 985 let path = package_root.join(&relative); 986 let canonical = path 987 .canonicalize() 988 .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 989 if canonical.parent() != Some(package_root.as_path()) { 990 return Err(ReleaseArtifactError::InvalidPackageInventory); 991 } 992 let bytes = read_bounded_regular( 993 &canonical, 994 MAX_TEXT_INPUT_BYTES, 995 ReleaseArtifactError::InvalidPackageInventory, 996 )?; 997 total = total 998 .checked_add(bytes.len() as u64) 999 .filter(|total| *total <= MAX_GENERATED_DOCUMENT_BYTES) 1000 .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1001 scan_bytes(&bytes)?; 1002 let text = 1003 String::from_utf8(bytes).map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; 1004 if text.trim().is_empty() { 1005 return Err(ReleaseArtifactError::InvalidPackageInventory); 1006 } 1007 texts.push(DependencyLicenseText { 1008 filename: relative 1009 .to_str() 1010 .ok_or(ReleaseArtifactError::InvalidPackageInventory)? 1011 .to_owned(), 1012 sha256: sha256_bytes(text.as_bytes()), 1013 text, 1014 }); 1015 } 1016 Ok(texts) 1017 } 1018 1019 fn build_supply_chain_documents( 1020 metadata: &ReleaseMetadata, 1021 cargo: CargoMetadata, 1022 ) -> Result<(CycloneDxSbom, String, String), ReleaseArtifactError> { 1023 if cargo.packages.is_empty() 1024 || cargo.packages.len() > MAX_PACKAGES 1025 || cargo.workspace_members.is_empty() 1026 || cargo.workspace_members.len() > MAX_WORKSPACE_PACKAGES 1027 { 1028 return Err(ReleaseArtifactError::InvalidPackageInventory); 1029 } 1030 let workspace = cargo.workspace_members.into_iter().collect::<BTreeSet<_>>(); 1031 let mut by_id = BTreeMap::new(); 1032 let mut root_id = None; 1033 for package in cargo.packages { 1034 validate_metadata_package(&package, workspace.contains(&package.id))?; 1035 if package.name == metadata.service_package 1036 && (root_id.replace(package.id.clone()).is_some() 1037 || package.version != metadata.version 1038 || !package.targets.iter().any(|target| { 1039 target.name == metadata.binary_name 1040 && target.kind.iter().any(|kind| kind == "bin") 1041 }) 1042 || !workspace.contains(&package.id)) 1043 { 1044 return Err(ReleaseArtifactError::InvalidPackageInventory); 1045 } 1046 if by_id.insert(package.id.clone(), package).is_some() { 1047 return Err(ReleaseArtifactError::InvalidPackageInventory); 1048 } 1049 } 1050 let root_id = root_id.ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1051 if by_id.len() > MAX_PACKAGES || !workspace.iter().all(|id| by_id.contains_key(id)) { 1052 return Err(ReleaseArtifactError::InvalidPackageInventory); 1053 } 1054 let references = by_id 1055 .values() 1056 .map(|package| (package.id.clone(), package_reference(package))) 1057 .collect::<BTreeMap<_, _>>(); 1058 let root_package = by_id 1059 .get(&root_id) 1060 .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1061 let root_component = sbom_component(root_package, "application")?; 1062 let mut components = by_id 1063 .values() 1064 .filter(|package| package.id != root_id) 1065 .map(|package| sbom_component(package, "library")) 1066 .collect::<Result<Vec<_>, _>>()?; 1067 components.sort(); 1068 let resolve = cargo 1069 .resolve 1070 .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1071 if resolve.nodes.len() != by_id.len() || resolve.nodes.len() > MAX_PACKAGES { 1072 return Err(ReleaseArtifactError::InvalidPackageInventory); 1073 } 1074 let mut seen_nodes = BTreeSet::new(); 1075 let mut dependencies = Vec::with_capacity(resolve.nodes.len()); 1076 for node in resolve.nodes { 1077 if !seen_nodes.insert(node.id.clone()) { 1078 return Err(ReleaseArtifactError::InvalidPackageInventory); 1079 } 1080 let reference = references 1081 .get(&node.id) 1082 .ok_or(ReleaseArtifactError::InvalidPackageInventory)? 1083 .clone(); 1084 let mut depends_on = node 1085 .dependencies 1086 .iter() 1087 .map(|dependency| { 1088 references 1089 .get(dependency) 1090 .cloned() 1091 .ok_or(ReleaseArtifactError::InvalidPackageInventory) 1092 }) 1093 .collect::<Result<Vec<_>, _>>()?; 1094 depends_on.sort(); 1095 depends_on.dedup(); 1096 dependencies.push(SbomDependency { 1097 reference, 1098 depends_on, 1099 }); 1100 } 1101 dependencies.sort_by(|left, right| left.reference.cmp(&right.reference)); 1102 let sbom = CycloneDxSbom { 1103 json_schema: "https://cyclonedx.org/schema/bom-1.6.schema.json", 1104 bom_format: "CycloneDX", 1105 spec_version: "1.6", 1106 version: 1, 1107 metadata: SbomMetadata { 1108 component: root_component, 1109 properties: vec![SbomProperty { 1110 name: "radroots:evidence:dependency-closure".to_owned(), 1111 value: "complete".to_owned(), 1112 }], 1113 }, 1114 components, 1115 dependencies, 1116 compositions: Vec::new(), 1117 }; 1118 1119 let mut third_party = by_id 1120 .values() 1121 .filter(|package| !workspace.contains(&package.id)) 1122 .collect::<Vec<_>>(); 1123 third_party.sort_by(|left, right| { 1124 (&left.name, &left.version, &left.source).cmp(&(&right.name, &right.version, &right.source)) 1125 }); 1126 let mut notices = String::from( 1127 "Radroots service third-party notices v1\n\nThis inventory is generated from the locked Cargo dependency graph.\n", 1128 ); 1129 let mut licenses = String::from( 1130 "Radroots service third-party license texts v1\n\nThis file contains the exact bounded license texts admitted for each locked third-party Cargo package.\n", 1131 ); 1132 if third_party.is_empty() { 1133 notices.push_str("\nNo third-party Cargo packages are present.\n"); 1134 licenses.push_str("\nNo third-party Cargo packages are present.\n"); 1135 } else { 1136 for package in third_party { 1137 let license = package 1138 .license 1139 .as_deref() 1140 .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1141 let source = package 1142 .source 1143 .as_deref() 1144 .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; 1145 use fmt::Write as _; 1146 writeln!(notices).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1147 writeln!(notices, "Package: {} {}", package.name, package.version) 1148 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1149 writeln!(notices, "License: {license}") 1150 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1151 writeln!(notices, "Source: {source}") 1152 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1153 if package.license_texts.is_empty() { 1154 return Err(ReleaseArtifactError::InvalidPackageInventory); 1155 } 1156 for license_text in &package.license_texts { 1157 if !valid_output_component(&license_text.filename) 1158 || license_text.text.trim().is_empty() 1159 || license_text.sha256 != sha256_bytes(license_text.text.as_bytes()) 1160 { 1161 return Err(ReleaseArtifactError::InvalidPackageInventory); 1162 } 1163 writeln!( 1164 notices, 1165 "License-Text: {} sha256:{}", 1166 license_text.filename, license_text.sha256 1167 ) 1168 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1169 writeln!(licenses).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1170 writeln!( 1171 licenses, 1172 "===== {} {} / {} / sha256:{} =====", 1173 package.name, package.version, license_text.filename, license_text.sha256 1174 ) 1175 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1176 licenses.push_str(&license_text.text); 1177 if !license_text.text.ends_with('\n') { 1178 licenses.push('\n'); 1179 } 1180 } 1181 } 1182 } 1183 scan_bytes(notices.as_bytes())?; 1184 scan_bytes(licenses.as_bytes())?; 1185 if licenses.len() as u64 > MAX_GENERATED_DOCUMENT_BYTES { 1186 return Err(ReleaseArtifactError::InvalidPackageInventory); 1187 } 1188 Ok((sbom, notices, licenses)) 1189 } 1190 1191 fn reconcile_sbom_artifacts(sbom: &mut CycloneDxSbom, artifacts: &[ArtifactRecord]) { 1192 let root_reference = sbom.metadata.component.bom_ref.clone(); 1193 let mut assemblies = Vec::with_capacity(artifacts.len()); 1194 for artifact in artifacts { 1195 let reference = format!("artifact:{}#{}", artifact.path, artifact.sha256); 1196 assemblies.push(reference.clone()); 1197 sbom.components.push(SbomComponent { 1198 component_type: "file", 1199 bom_ref: reference.clone(), 1200 name: artifact.path.clone(), 1201 version: artifact.sha256.clone(), 1202 purl: None, 1203 licenses: sbom.metadata.component.licenses.clone(), 1204 hashes: vec![DigestValue { 1205 alg: "SHA-256", 1206 content: artifact.sha256.clone(), 1207 }], 1208 properties: vec![ 1209 SbomProperty { 1210 name: "radroots:artifact:byte-length".to_owned(), 1211 value: artifact.byte_length.to_string(), 1212 }, 1213 SbomProperty { 1214 name: "radroots:ecosystem".to_owned(), 1215 value: artifact_ecosystem(&artifact.path).to_owned(), 1216 }, 1217 ], 1218 }); 1219 sbom.dependencies.push(SbomDependency { 1220 reference, 1221 depends_on: vec![root_reference.clone()], 1222 }); 1223 } 1224 sbom.components.sort(); 1225 sbom.dependencies 1226 .sort_by(|left, right| left.reference.cmp(&right.reference)); 1227 assemblies.sort(); 1228 let mut dependencies = 1229 sbom.components 1230 .iter() 1231 .filter(|component| { 1232 component.properties.iter().any(|property| { 1233 property.name == "radroots:ecosystem" && property.value == "cargo" 1234 }) 1235 }) 1236 .map(|component| component.bom_ref.clone()) 1237 .collect::<Vec<_>>(); 1238 dependencies.push(root_reference); 1239 dependencies.sort(); 1240 dependencies.dedup(); 1241 sbom.compositions = vec![SbomComposition { 1242 aggregate: "complete", 1243 assemblies, 1244 dependencies, 1245 }]; 1246 } 1247 1248 fn validate_cyclonedx_profile( 1249 sbom: &CycloneDxSbom, 1250 artifacts: &[ArtifactRecord], 1251 ) -> Result<(), ReleaseArtifactError> { 1252 let schema = serde_json::json!({ 1253 "$schema": "https://json-schema.org/draft/2020-12/schema", 1254 "type": "object", 1255 "additionalProperties": false, 1256 "required": ["$schema", "bomFormat", "specVersion", "version", "metadata", "components", "dependencies", "compositions"], 1257 "properties": { 1258 "$schema": {"const": "https://cyclonedx.org/schema/bom-1.6.schema.json"}, 1259 "bomFormat": {"const": "CycloneDX"}, 1260 "specVersion": {"const": "1.6"}, 1261 "version": {"const": 1}, 1262 "metadata": {"type": "object"}, 1263 "components": {"type": "array", "minItems": 1}, 1264 "dependencies": {"type": "array", "minItems": 1}, 1265 "compositions": {"type": "array", "minItems": 1, "maxItems": 1} 1266 } 1267 }); 1268 let validator = 1269 jsonschema::validator_for(&schema).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1270 let value = serde_json::to_value(sbom).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1271 validator 1272 .validate(&value) 1273 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1274 1275 let component_references = sbom 1276 .components 1277 .iter() 1278 .map(|component| component.bom_ref.as_str()) 1279 .chain(std::iter::once(sbom.metadata.component.bom_ref.as_str())) 1280 .collect::<BTreeSet<_>>(); 1281 if component_references.len() != sbom.components.len() + 1 1282 || sbom.dependencies.len() != component_references.len() 1283 || sbom.dependencies.iter().any(|dependency| { 1284 !component_references.contains(dependency.reference.as_str()) 1285 || dependency 1286 .depends_on 1287 .iter() 1288 .any(|reference| !component_references.contains(reference.as_str())) 1289 }) 1290 { 1291 return Err(ReleaseArtifactError::GenerationFailure); 1292 } 1293 let composition = sbom 1294 .compositions 1295 .first() 1296 .ok_or(ReleaseArtifactError::GenerationFailure)?; 1297 let expected_artifacts = artifacts 1298 .iter() 1299 .map(|artifact| format!("artifact:{}#{}", artifact.path, artifact.sha256)) 1300 .collect::<BTreeSet<_>>(); 1301 if composition.aggregate != "complete" 1302 || composition.assemblies.iter().collect::<BTreeSet<_>>() 1303 != expected_artifacts.iter().collect::<BTreeSet<_>>() 1304 || artifacts.iter().any(|artifact| { 1305 sbom.components 1306 .iter() 1307 .filter(|component| { 1308 component.bom_ref == format!("artifact:{}#{}", artifact.path, artifact.sha256) 1309 && component.hashes 1310 == [DigestValue { 1311 alg: "SHA-256", 1312 content: artifact.sha256.clone(), 1313 }] 1314 }) 1315 .count() 1316 != 1 1317 }) 1318 { 1319 return Err(ReleaseArtifactError::GenerationFailure); 1320 } 1321 Ok(()) 1322 } 1323 1324 fn validate_provenance_subjects( 1325 provenance: &InTotoStatement, 1326 candidate_digest: &str, 1327 artifacts: &[ArtifactRecord], 1328 ) -> Result<(), ReleaseArtifactError> { 1329 let expected = artifacts 1330 .iter() 1331 .map(|artifact| (artifact.path.as_str(), artifact.sha256.as_str())) 1332 .collect::<BTreeSet<_>>(); 1333 let observed = provenance 1334 .subject 1335 .iter() 1336 .filter_map(|subject| { 1337 subject 1338 .digest 1339 .get("sha256") 1340 .map(|digest| (subject.name.as_str(), digest.as_str())) 1341 }) 1342 .collect::<BTreeSet<_>>(); 1343 if provenance.statement_type != "https://in-toto.io/Statement/v1" 1344 || provenance.predicate_type != "https://slsa.dev/provenance/v1" 1345 || provenance 1346 .predicate 1347 .build_definition 1348 .external_parameters 1349 .candidate_digest 1350 != format!("{CANDIDATE_DIGEST_DOMAIN}{candidate_digest}") 1351 || observed != expected 1352 || provenance.subject.len() != expected.len() 1353 { 1354 return Err(ReleaseArtifactError::GenerationFailure); 1355 } 1356 Ok(()) 1357 } 1358 1359 fn artifact_ecosystem(path: &str) -> &'static str { 1360 if path == "oci-image.tar.gz" || path == "nixos-module.nix" { 1361 "nix" 1362 } else if path.ends_with("-source.tar") { 1363 "git" 1364 } else if path == "binary.tar.gz" { 1365 "native" 1366 } else { 1367 "release" 1368 } 1369 } 1370 1371 struct ProvenanceInput<'a> { 1372 candidate_digest: &'a str, 1373 service: &'a str, 1374 target: &'a str, 1375 source_date_epoch: u32, 1376 service_repository: &'a str, 1377 service_revision: &'a str, 1378 lib_revision: &'a str, 1379 source_lock_sha256: &'a str, 1380 manifest_sha256: &'a str, 1381 } 1382 1383 fn build_provenance(input: ProvenanceInput<'_>, artifacts: &[ArtifactRecord]) -> InTotoStatement { 1384 let ProvenanceInput { 1385 candidate_digest, 1386 service, 1387 target, 1388 source_date_epoch, 1389 service_repository, 1390 service_revision, 1391 lib_revision, 1392 source_lock_sha256, 1393 manifest_sha256, 1394 } = input; 1395 let mut subject = artifacts 1396 .iter() 1397 .map(|artifact| InTotoSubject { 1398 name: artifact.path.clone(), 1399 digest: BTreeMap::from([("sha256", artifact.sha256.clone())]), 1400 }) 1401 .collect::<Vec<_>>(); 1402 subject.sort_by(|left, right| left.name.cmp(&right.name)); 1403 let mut resolved_dependencies = vec![ 1404 SlsaResolvedDependency { 1405 uri: format!("git+{service_repository}@{service_revision}"), 1406 digest: BTreeMap::from([("gitCommit", service_revision.to_owned())]), 1407 }, 1408 SlsaResolvedDependency { 1409 uri: format!("git+{LIB_REPOSITORY}@{lib_revision}"), 1410 digest: BTreeMap::from([("gitCommit", lib_revision.to_owned())]), 1411 }, 1412 SlsaResolvedDependency { 1413 uri: format!("file:{LOCK_FILENAME}"), 1414 digest: BTreeMap::from([("sha256", source_lock_sha256.to_owned())]), 1415 }, 1416 SlsaResolvedDependency { 1417 uri: "file:artifact-manifest.v2.json".to_owned(), 1418 digest: BTreeMap::from([("sha256", manifest_sha256.to_owned())]), 1419 }, 1420 ]; 1421 resolved_dependencies.sort_by(|left, right| left.uri.cmp(&right.uri)); 1422 let invocation_id = sha256_bytes( 1423 format!( 1424 "radroots.service.slsa.invocation.v1\0{candidate_digest}\0{service}\0{target}\0{manifest_sha256}" 1425 ) 1426 .as_bytes(), 1427 ); 1428 InTotoStatement { 1429 statement_type: "https://in-toto.io/Statement/v1", 1430 subject, 1431 predicate_type: "https://slsa.dev/provenance/v1", 1432 predicate: SlsaPredicate { 1433 build_definition: SlsaBuildDefinition { 1434 build_type: "https://radroots.dev/contracts/service-release-artifacts/v2", 1435 external_parameters: SlsaExternalParameters { 1436 candidate_digest: format!("{CANDIDATE_DIGEST_DOMAIN}{candidate_digest}"), 1437 service: service.to_owned(), 1438 target: target.to_owned(), 1439 source_date_epoch, 1440 }, 1441 internal_parameters: BTreeMap::new(), 1442 resolved_dependencies, 1443 }, 1444 run_details: SlsaRunDetails { 1445 builder: SlsaBuilder { 1446 id: "https://radroots.dev/builders/service-release-artifacts/v2", 1447 }, 1448 metadata: SlsaRunMetadata { invocation_id }, 1449 }, 1450 }, 1451 } 1452 } 1453 1454 fn scanner_ruleset_sha256() -> String { 1455 let mut bytes = b"radroots.service.artifact-scan.rules.v1\0".to_vec(); 1456 for pattern in SECRET_PATTERNS { 1457 bytes.extend_from_slice(pattern); 1458 bytes.push(0); 1459 } 1460 sha256_bytes(&bytes) 1461 } 1462 1463 fn scan_artifact_inventory( 1464 root: &Path, 1465 artifacts: &[ArtifactRecord], 1466 ) -> Result<(u64, u64), ReleaseArtifactError> { 1467 let mut nested_members = 0_u64; 1468 let mut expanded_bytes = 0_u64; 1469 for artifact in artifacts { 1470 let path = root.join(&artifact.path); 1471 expanded_bytes = expanded_bytes 1472 .checked_add(scan_regular_file(&path, artifact.byte_length)?) 1473 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1474 if artifact.path == "service-source.tar" || artifact.path == "lib-source.tar" { 1475 let (members, bytes) = scan_tar_members(&path, false)?; 1476 nested_members = nested_members 1477 .checked_add(members) 1478 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1479 expanded_bytes = expanded_bytes 1480 .checked_add(bytes) 1481 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1482 } else if artifact.path == "binary.tar.gz" { 1483 let (members, bytes) = scan_tar_gzip_members(&path)?; 1484 nested_members = nested_members 1485 .checked_add(members) 1486 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1487 expanded_bytes = expanded_bytes 1488 .checked_add(bytes) 1489 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1490 } else if artifact.path == "oci-image.tar.gz" { 1491 let limits = TarGzipLimits { 1492 max_compressed_bytes: MAX_OCI_BYTES, 1493 max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 1494 max_members: 65_536, 1495 max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 1496 max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 1497 max_depth: 64, 1498 max_path_bytes: MAX_ARCHIVE_PATH_BYTES, 1499 }; 1500 let materialized = 1501 safe_artifact_io::materialize_tar_gzip_path(path.as_path(), root, limits) 1502 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1503 for file in materialized.snapshot().files() { 1504 let relative = file 1505 .relative_path() 1506 .to_str() 1507 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1508 validate_scanned_path(relative)?; 1509 let evidence = materialized 1510 .snapshot() 1511 .hash(file, MAX_ARCHIVE_EXPANDED_BYTES) 1512 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1513 expanded_bytes = expanded_bytes 1514 .checked_add(scan_regular_file( 1515 &materialized.root().join(file.relative_path()), 1516 evidence.byte_length, 1517 )?) 1518 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1519 nested_members = nested_members 1520 .checked_add(1) 1521 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1522 if relative.ends_with("/layer.tar") { 1523 let (members, bytes) = 1524 scan_tar_members(&materialized.root().join(file.relative_path()), true)?; 1525 nested_members = nested_members 1526 .checked_add(members) 1527 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1528 expanded_bytes = expanded_bytes 1529 .checked_add(bytes) 1530 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1531 } 1532 } 1533 materialized 1534 .revalidate() 1535 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1536 } 1537 } 1538 Ok((nested_members, expanded_bytes)) 1539 } 1540 1541 fn scan_tar_gzip_members(path: &Path) -> Result<(u64, u64), ReleaseArtifactError> { 1542 let file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1543 let decoder = flate2::read::GzDecoder::new(file); 1544 let mut archive = tar::Archive::new(decoder); 1545 let mut members = 0_u64; 1546 let mut bytes = 0_u64; 1547 for entry in archive 1548 .entries() 1549 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)? 1550 { 1551 let mut entry = entry.map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1552 members = members 1553 .checked_add(1) 1554 .filter(|count| *count <= 4) 1555 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1556 let path = entry 1557 .path() 1558 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1559 let path = path 1560 .to_str() 1561 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1562 validate_scanned_path(path)?; 1563 if entry.header().entry_type().is_file() { 1564 let size = entry.size(); 1565 if size > MAX_BINARY_BYTES { 1566 return Err(ReleaseArtifactError::InvalidInputArtifact); 1567 } 1568 bytes = bytes 1569 .checked_add(scan_reader(&mut entry, size)?) 1570 .filter(|count| *count <= MAX_BINARY_BYTES) 1571 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1572 } else if !entry.header().entry_type().is_dir() { 1573 return Err(ReleaseArtifactError::InvalidInputArtifact); 1574 } 1575 } 1576 if members == 0 { 1577 Err(ReleaseArtifactError::InvalidInputArtifact) 1578 } else { 1579 Ok((members, bytes)) 1580 } 1581 } 1582 1583 fn scan_tar_members(path: &Path, links_allowed: bool) -> Result<(u64, u64), ReleaseArtifactError> { 1584 let file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1585 let mut archive = tar::Archive::new(file); 1586 let mut members = 0_u64; 1587 let mut bytes = 0_u64; 1588 for entry in archive 1589 .entries() 1590 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)? 1591 { 1592 let mut entry = entry.map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1593 let path = entry 1594 .path() 1595 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1596 let path = path 1597 .to_str() 1598 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1599 validate_scanned_path(path)?; 1600 members = members 1601 .checked_add(1) 1602 .filter(|count| *count <= MAX_SOURCE_ARCHIVE_MEMBERS) 1603 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1604 if entry.header().entry_type().is_file() { 1605 let size = entry.size(); 1606 if !links_allowed && size > MAX_SOURCE_ARCHIVE_MEMBER_BYTES { 1607 return Err(ReleaseArtifactError::InvalidInputArtifact); 1608 } 1609 let scanned = scan_reader(&mut entry, size)?; 1610 bytes = bytes 1611 .checked_add(scanned) 1612 .filter(|count| *count <= MAX_ARCHIVE_EXPANDED_BYTES) 1613 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1614 } else if !links_allowed 1615 || !(entry.header().entry_type().is_dir() 1616 || entry.header().entry_type().is_symlink() 1617 || entry.header().entry_type().is_hard_link()) 1618 { 1619 return Err(ReleaseArtifactError::InvalidInputArtifact); 1620 } 1621 } 1622 if members == 0 { 1623 Err(ReleaseArtifactError::InvalidInputArtifact) 1624 } else { 1625 Ok((members, bytes)) 1626 } 1627 } 1628 1629 fn scan_regular_file(path: &Path, expected_length: u64) -> Result<u64, ReleaseArtifactError> { 1630 let mut file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1631 scan_reader(&mut file, expected_length) 1632 } 1633 1634 fn scan_reader( 1635 reader: &mut impl std::io::Read, 1636 expected_length: u64, 1637 ) -> Result<u64, ReleaseArtifactError> { 1638 let mut scanner = SecretScanner::default(); 1639 let mut total = 0_u64; 1640 let mut buffer = [0_u8; 64 * 1024]; 1641 loop { 1642 let read = reader 1643 .read(&mut buffer) 1644 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1645 if read == 0 { 1646 break; 1647 } 1648 total = total 1649 .checked_add(read as u64) 1650 .filter(|value| *value <= expected_length) 1651 .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; 1652 scanner.scan(&buffer[..read])?; 1653 } 1654 if total == expected_length { 1655 Ok(total) 1656 } else { 1657 Err(ReleaseArtifactError::InvalidInputArtifact) 1658 } 1659 } 1660 1661 fn validate_scanned_path(path: &str) -> Result<(), ReleaseArtifactError> { 1662 let sensitive = Path::new(path).components().any(|component| { 1663 let Component::Normal(value) = component else { 1664 return true; 1665 }; 1666 let value = value.to_string_lossy().to_ascii_lowercase(); 1667 matches!( 1668 value.as_str(), 1669 ".git" 1670 | ".ssh" 1671 | ".aws" 1672 | ".env" 1673 | "credentials" 1674 | "secrets" 1675 | "id_rsa" 1676 | "id_ed25519" 1677 | "private_key" 1678 ) 1679 }); 1680 if sensitive || path.len() > MAX_ARCHIVE_PATH_BYTES { 1681 Err(ReleaseArtifactError::ProtectedMaterialDetected) 1682 } else { 1683 Ok(()) 1684 } 1685 } 1686 1687 fn validate_metadata_package( 1688 package: &CargoPackage, 1689 workspace_member: bool, 1690 ) -> Result<(), ReleaseArtifactError> { 1691 for value in [&package.name, &package.version, &package.id] { 1692 if value.is_empty() || value.len() > MAX_TEXT_FIELD_BYTES || value.contains(['\n', '\r']) { 1693 return Err(ReleaseArtifactError::InvalidPackageInventory); 1694 } 1695 } 1696 if package 1697 .license 1698 .as_ref() 1699 .is_some_and(|value| !valid_metadata_text(value)) 1700 || package 1701 .source 1702 .as_ref() 1703 .is_some_and(|value| !valid_public_source(value)) 1704 || (!workspace_member && (package.source.is_none() || package.license.is_none())) 1705 || package 1706 .checksum 1707 .as_ref() 1708 .is_some_and(|value| !valid_lower_hex(value, 64)) 1709 { 1710 return Err(ReleaseArtifactError::InvalidPackageInventory); 1711 } 1712 Ok(()) 1713 } 1714 1715 fn sbom_component( 1716 package: &CargoPackage, 1717 component_type: &'static str, 1718 ) -> Result<SbomComponent, ReleaseArtifactError> { 1719 let license = package 1720 .license 1721 .clone() 1722 .unwrap_or_else(|| "NOASSERTION".to_owned()); 1723 let hashes = package 1724 .checksum 1725 .iter() 1726 .map(|checksum| DigestValue { 1727 alg: "SHA-256", 1728 content: checksum.clone(), 1729 }) 1730 .collect(); 1731 Ok(SbomComponent { 1732 component_type, 1733 bom_ref: package_reference(package), 1734 name: package.name.clone(), 1735 version: package.version.clone(), 1736 purl: Some(format!("pkg:cargo/{}@{}", package.name, package.version)), 1737 licenses: vec![LicenseChoice { 1738 expression: license, 1739 }], 1740 hashes, 1741 properties: vec![SbomProperty { 1742 name: "radroots:ecosystem".to_owned(), 1743 value: "cargo".to_owned(), 1744 }], 1745 }) 1746 } 1747 1748 fn package_reference(package: &CargoPackage) -> String { 1749 let source = package.source.as_deref().unwrap_or("workspace"); 1750 let source_digest = sha256_bytes(source.as_bytes()); 1751 format!( 1752 "cargo:{}@{}#{}", 1753 package.name, 1754 package.version, 1755 &source_digest[..16] 1756 ) 1757 } 1758 1759 fn validate_exact_input_root( 1760 path: &Path, 1761 ) -> Result<(PathBuf, safe_artifact_io::TraversalSnapshot), ReleaseArtifactError> { 1762 let root = validate_absolute_directory(path, ReleaseArtifactError::InvalidInputRoot)?; 1763 let snapshot = flat_directory_snapshot(&root, ReleaseArtifactError::InvalidInputRoot)?; 1764 let inventory = snapshot_inventory(&snapshot, ReleaseArtifactError::InvalidInputRoot)?; 1765 if inventory == INPUT_NAMES.into_iter().map(str::to_owned).collect() { 1766 Ok((root, snapshot)) 1767 } else { 1768 Err(ReleaseArtifactError::InvalidInputRoot) 1769 } 1770 } 1771 1772 fn validate_output_parent( 1773 output: &Path, 1774 service_root: &Path, 1775 input_root: &Path, 1776 ) -> Result<(PathBuf, PathBuf), ReleaseArtifactError> { 1777 if !output.is_absolute() 1778 || output 1779 .file_name() 1780 .and_then(OsStr::to_str) 1781 .is_none_or(|name| !valid_output_component(name)) 1782 { 1783 return Err(ReleaseArtifactError::InvalidOutputRoot); 1784 } 1785 let parent = output 1786 .parent() 1787 .ok_or(ReleaseArtifactError::InvalidOutputRoot)?; 1788 let parent = validate_absolute_directory(parent, ReleaseArtifactError::InvalidOutputRoot)?; 1789 let output = parent.join( 1790 output 1791 .file_name() 1792 .ok_or(ReleaseArtifactError::InvalidOutputRoot)?, 1793 ); 1794 if output.starts_with(service_root) 1795 || output.starts_with(input_root) 1796 || service_root.starts_with(&output) 1797 || input_root.starts_with(&output) 1798 { 1799 return Err(ReleaseArtifactError::InvalidOutputRoot); 1800 } 1801 if let Ok(metadata) = fs::symlink_metadata(&output) 1802 && (metadata.file_type().is_symlink() || !metadata.is_dir()) 1803 { 1804 return Err(ReleaseArtifactError::InvalidOutputRoot); 1805 } 1806 Ok((parent, output)) 1807 } 1808 1809 fn validate_git_root(path: &Path) -> Result<PathBuf, ReleaseArtifactError> { 1810 let root = validate_absolute_directory(path, ReleaseArtifactError::InvalidServiceRoot)?; 1811 let top = git_stdout( 1812 &root, 1813 ["rev-parse", "--show-toplevel"], 1814 MAX_GIT_OUTPUT_BYTES, 1815 ) 1816 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; 1817 let top = std::str::from_utf8(&top) 1818 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? 1819 .trim(); 1820 let top = fs::canonicalize(top).map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; 1821 if top == root { 1822 Ok(root) 1823 } else { 1824 Err(ReleaseArtifactError::InvalidServiceRoot) 1825 } 1826 } 1827 1828 fn validate_absolute_directory( 1829 path: &Path, 1830 error: ReleaseArtifactError, 1831 ) -> Result<PathBuf, ReleaseArtifactError> { 1832 if !path.is_absolute() { 1833 return Err(error); 1834 } 1835 let metadata = fs::symlink_metadata(path).map_err(|_| error)?; 1836 if metadata.file_type().is_symlink() || !metadata.is_dir() { 1837 return Err(error); 1838 } 1839 fs::canonicalize(path).map_err(|_| error) 1840 } 1841 1842 fn validate_clean_git(root: &Path) -> Result<(), ReleaseArtifactError> { 1843 let status = git_stdout( 1844 root, 1845 ["status", "--porcelain=v1", "-z", "--untracked-files=all"], 1846 MAX_GIT_OUTPUT_BYTES, 1847 ) 1848 .map_err(|_| ReleaseArtifactError::DirtyServiceSource)?; 1849 if status.is_empty() { 1850 Ok(()) 1851 } else { 1852 Err(ReleaseArtifactError::DirtyServiceSource) 1853 } 1854 } 1855 1856 fn git_head(root: &Path) -> Result<String, ReleaseArtifactError> { 1857 let bytes = git_stdout(root, ["rev-parse", "HEAD"], 128) 1858 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; 1859 let value = std::str::from_utf8(&bytes) 1860 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? 1861 .trim(); 1862 if valid_lower_hex(value, 40) { 1863 Ok(value.to_owned()) 1864 } else { 1865 Err(ReleaseArtifactError::InvalidServiceRoot) 1866 } 1867 } 1868 1869 fn git_remote(root: &Path) -> Result<String, ReleaseArtifactError> { 1870 let bytes = git_stdout(root, ["remote", "get-url", "origin"], MAX_GIT_OUTPUT_BYTES) 1871 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; 1872 let value = std::str::from_utf8(&bytes) 1873 .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? 1874 .trim(); 1875 if value.len() > MAX_TEXT_FIELD_BYTES 1876 || !(value.starts_with("https://github.com/") || value.starts_with("ssh://git@github.com/")) 1877 || value.contains(['\n', '\r']) 1878 { 1879 return Err(ReleaseArtifactError::InvalidServiceRoot); 1880 } 1881 Ok(value.to_owned()) 1882 } 1883 1884 #[cfg(test)] 1885 fn create_binary_archive( 1886 source: &Path, 1887 output: &Path, 1888 binary_name: &str, 1889 source_date_epoch: u32, 1890 ) -> Result<FileEvidence, ReleaseArtifactError> { 1891 let (_stable, stable_source) = binary_staging(output)?; 1892 let source_evidence = 1893 safe_artifact_io::copy_regular_to_new_path(source, &stable_source, MAX_BINARY_BYTES) 1894 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1895 if source_evidence.byte_length == 0 { 1896 return Err(ReleaseArtifactError::InvalidInputArtifact); 1897 } 1898 write_binary_archive( 1899 &stable_source, 1900 output, 1901 binary_name, 1902 source_date_epoch, 1903 source_evidence, 1904 ) 1905 } 1906 1907 fn create_binary_archive_from_snapshot( 1908 snapshot: &safe_artifact_io::TraversalSnapshot, 1909 source_name: &str, 1910 output: &Path, 1911 binary_name: &str, 1912 target: &str, 1913 source_date_epoch: u32, 1914 ) -> Result<FileEvidence, ReleaseArtifactError> { 1915 let source = snapshot_file(snapshot, source_name)?; 1916 let (_stable, stable_source) = binary_staging(output)?; 1917 let source_evidence = snapshot 1918 .copy_to_new_path(source, &stable_source, MAX_BINARY_BYTES) 1919 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1920 if source_evidence.byte_length == 0 { 1921 return Err(ReleaseArtifactError::InvalidInputArtifact); 1922 } 1923 artifact_admission::admit_binary(&stable_source, target, true) 1924 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 1925 write_binary_archive( 1926 &stable_source, 1927 output, 1928 binary_name, 1929 source_date_epoch, 1930 source_evidence, 1931 ) 1932 } 1933 1934 fn binary_staging(output: &Path) -> Result<(TempDir, PathBuf), ReleaseArtifactError> { 1935 let parent = output 1936 .parent() 1937 .ok_or(ReleaseArtifactError::GenerationFailure)?; 1938 let stable = tempfile::Builder::new() 1939 .prefix(".radroots-binary-input-") 1940 .tempdir_in(parent) 1941 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1942 let stable_root = stable 1943 .path() 1944 .canonicalize() 1945 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1946 Ok((stable, stable_root.join("admitted-binary"))) 1947 } 1948 1949 fn write_binary_archive( 1950 stable_source: &Path, 1951 output: &Path, 1952 binary_name: &str, 1953 source_date_epoch: u32, 1954 source_evidence: safe_artifact_io::FileEvidence, 1955 ) -> Result<FileEvidence, ReleaseArtifactError> { 1956 let mut source_file = 1957 fs::File::open(stable_source).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1958 let output_file = fs::OpenOptions::new() 1959 .create_new(true) 1960 .write(true) 1961 .open(output) 1962 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1963 let encoder = GzBuilder::new() 1964 .mtime(source_date_epoch) 1965 .operating_system(255) 1966 .write(output_file, Compression::best()); 1967 let mut archive = TarBuilder::new(encoder); 1968 let mut header = TarHeader::new_gnu(); 1969 header.set_size(source_evidence.byte_length); 1970 header.set_mode(0o755); 1971 header.set_uid(0); 1972 header.set_gid(0); 1973 header.set_mtime(u64::from(source_date_epoch)); 1974 header.set_cksum(); 1975 archive 1976 .append_data(&mut header, format!("bin/{binary_name}"), &mut source_file) 1977 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1978 let encoder = archive 1979 .into_inner() 1980 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1981 let output_file = encoder 1982 .finish() 1983 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1984 output_file 1985 .sync_all() 1986 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 1987 set_file_mode(output)?; 1988 admit_binary_archive(output)?; 1989 hash_regular(output, MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES) 1990 } 1991 1992 fn snapshot_file<'a>( 1993 snapshot: &'a safe_artifact_io::TraversalSnapshot, 1994 name: &str, 1995 ) -> Result<&'a safe_artifact_io::TraversedFile, ReleaseArtifactError> { 1996 snapshot 1997 .files() 1998 .iter() 1999 .find(|file| file.relative_path() == Path::new(name)) 2000 .ok_or(ReleaseArtifactError::InvalidInputRoot) 2001 } 2002 2003 fn copy_snapshot_file( 2004 snapshot: &safe_artifact_io::TraversalSnapshot, 2005 source_name: &str, 2006 output: &Path, 2007 maximum: u64, 2008 ) -> Result<FileEvidence, ReleaseArtifactError> { 2009 let evidence = snapshot 2010 .copy_to_new_path(snapshot_file(snapshot, source_name)?, output, maximum) 2011 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2012 set_file_mode(output)?; 2013 Ok(FileEvidence { 2014 byte_length: evidence.byte_length, 2015 sha256: evidence.sha256, 2016 }) 2017 } 2018 2019 fn copy_bounded( 2020 source: &Path, 2021 output: &Path, 2022 maximum: u64, 2023 ) -> Result<FileEvidence, ReleaseArtifactError> { 2024 let evidence = safe_artifact_io::copy_regular_to_new_path(source, output, maximum) 2025 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2026 set_file_mode(output)?; 2027 Ok(FileEvidence { 2028 byte_length: evidence.byte_length, 2029 sha256: evidence.sha256, 2030 }) 2031 } 2032 2033 fn admit_binary_archive(path: &Path) -> Result<(), ReleaseArtifactError> { 2034 let limits = TarGzipLimits { 2035 max_compressed_bytes: MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES, 2036 max_expanded_bytes: MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES, 2037 max_members: 4, 2038 max_member_bytes: MAX_BINARY_BYTES, 2039 max_payload_bytes: MAX_BINARY_BYTES, 2040 max_depth: 4, 2041 max_path_bytes: MAX_ARCHIVE_PATH_BYTES, 2042 }; 2043 safe_artifact_io::admit_tar_gzip_path(path, limits) 2044 .map(|_| ()) 2045 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact) 2046 } 2047 2048 #[derive(Default)] 2049 struct SecretScanner { 2050 tail: Vec<u8>, 2051 } 2052 2053 impl SecretScanner { 2054 fn scan(&mut self, bytes: &[u8]) -> Result<(), ReleaseArtifactError> { 2055 let mut combined = Vec::with_capacity(self.tail.len() + bytes.len()); 2056 combined.extend_from_slice(&self.tail); 2057 combined.extend_from_slice(bytes); 2058 if contains_secret(&combined) { 2059 return Err(ReleaseArtifactError::ProtectedMaterialDetected); 2060 } 2061 let retained = SECRET_SCAN_OVERLAP_BYTES.min(combined.len()); 2062 self.tail.clear(); 2063 self.tail 2064 .extend_from_slice(&combined[combined.len() - retained..]); 2065 Ok(()) 2066 } 2067 } 2068 2069 fn contains_secret(bytes: &[u8]) -> bool { 2070 let pem = [ 2071 ( 2072 b"-----BEGIN PRIVATE KEY-----".as_slice(), 2073 b"-----END PRIVATE KEY-----".as_slice(), 2074 ), 2075 ( 2076 b"-----BEGIN RSA PRIVATE KEY-----".as_slice(), 2077 b"-----END RSA PRIVATE KEY-----".as_slice(), 2078 ), 2079 ( 2080 b"-----BEGIN EC PRIVATE KEY-----".as_slice(), 2081 b"-----END EC PRIVATE KEY-----".as_slice(), 2082 ), 2083 ( 2084 b"-----BEGIN OPENSSH PRIVATE KEY-----".as_slice(), 2085 b"-----END OPENSSH PRIVATE KEY-----".as_slice(), 2086 ), 2087 ] 2088 .iter() 2089 .any(|(begin, end)| contains_pem_secret(bytes, begin, end)); 2090 let github_pat = contains_prefixed_secret(bytes, b"github_pat_", 50, 128, is_token_byte); 2091 let ghp = contains_prefixed_secret(bytes, b"ghp_", 36, 36, |byte| byte.is_ascii_alphanumeric()); 2092 let slack = contains_prefixed_secret(bytes, b"xoxb-", 20, 128, |byte| { 2093 byte.is_ascii_digit() || byte == b'-' 2094 }); 2095 pem || github_pat || ghp || slack 2096 } 2097 2098 fn contains_pem_secret(bytes: &[u8], begin: &[u8], end: &[u8]) -> bool { 2099 let Some(begin_at) = bytes 2100 .windows(begin.len()) 2101 .position(|window| window == begin) 2102 else { 2103 return false; 2104 }; 2105 let body = &bytes[begin_at + begin.len()..]; 2106 if !body.starts_with(b"\n") && !body.starts_with(b"\r\n") { 2107 return false; 2108 } 2109 let Some(end_at) = body.windows(end.len()).position(|window| window == end) else { 2110 return false; 2111 }; 2112 let encoded = &body[..end_at]; 2113 encoded.iter().all(|byte| { 2114 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'\n' | b'\r') 2115 }) && encoded 2116 .iter() 2117 .filter(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')) 2118 .count() 2119 >= 32 2120 } 2121 2122 fn contains_prefixed_secret( 2123 bytes: &[u8], 2124 prefix: &[u8], 2125 minimum_suffix: usize, 2126 maximum_suffix: usize, 2127 valid_suffix: impl Fn(u8) -> bool, 2128 ) -> bool { 2129 bytes 2130 .windows(prefix.len()) 2131 .enumerate() 2132 .any(|(index, window)| { 2133 if window != prefix 2134 || index 2135 .checked_sub(1) 2136 .is_some_and(|prior| is_token_byte(bytes[prior])) 2137 { 2138 return false; 2139 } 2140 let start = index + prefix.len(); 2141 let suffix = bytes[start..] 2142 .iter() 2143 .take_while(|byte| valid_suffix(**byte)) 2144 .count(); 2145 (minimum_suffix..=maximum_suffix).contains(&suffix) 2146 && bytes 2147 .get(start + suffix) 2148 .is_none_or(|byte| !is_token_byte(*byte)) 2149 }) 2150 } 2151 2152 fn is_token_byte(byte: u8) -> bool { 2153 byte.is_ascii_alphanumeric() || byte == b'_' 2154 } 2155 2156 fn scan_bytes(bytes: &[u8]) -> Result<(), ReleaseArtifactError> { 2157 let mut scanner = SecretScanner::default(); 2158 scanner.scan(bytes) 2159 } 2160 2161 fn validate_text_artifact(path: &Path, name: &str) -> Result<(), ReleaseArtifactError> { 2162 let bytes = read_bounded_regular( 2163 path, 2164 MAX_TEXT_INPUT_BYTES, 2165 ReleaseArtifactError::InvalidInputArtifact, 2166 )?; 2167 std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2168 if bytes.contains(&0) { 2169 return Err(ReleaseArtifactError::InvalidInputArtifact); 2170 } 2171 scan_bytes(&bytes)?; 2172 if name == "config.schema.json" { 2173 serde_json::from_slice::<serde_json::Value>(&bytes) 2174 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2175 } else if name == "config.example.toml" { 2176 toml::from_str::<toml::Value>( 2177 std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?, 2178 ) 2179 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2180 } 2181 Ok(()) 2182 } 2183 2184 fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ReleaseArtifactError> { 2185 let mut bytes = 2186 serde_json::to_vec(value).map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2187 bytes.push(b'\n'); 2188 write_generated(path, &bytes) 2189 } 2190 2191 fn write_generated(path: &Path, bytes: &[u8]) -> Result<(), ReleaseArtifactError> { 2192 if bytes.is_empty() || bytes.len() as u64 > MAX_GENERATED_DOCUMENT_BYTES { 2193 return Err(ReleaseArtifactError::GenerationFailure); 2194 } 2195 scan_bytes(bytes)?; 2196 let mut file = fs::OpenOptions::new() 2197 .create_new(true) 2198 .write(true) 2199 .open(path) 2200 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2201 file.write_all(bytes) 2202 .and_then(|()| file.sync_all()) 2203 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2204 set_file_mode(path) 2205 } 2206 2207 fn write_checksums(root: &Path) -> Result<(), ReleaseArtifactError> { 2208 let mut records = inventory_records(root)?; 2209 records.sort(); 2210 let mut checksums = String::new(); 2211 use fmt::Write as _; 2212 for record in records { 2213 writeln!(checksums, "{} {}", record.sha256, record.path) 2214 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2215 } 2216 write_generated(&root.join("SHA256SUMS"), checksums.as_bytes()) 2217 } 2218 2219 fn inventory_records(root: &Path) -> Result<Vec<ArtifactRecord>, ReleaseArtifactError> { 2220 inventory_records_impl(root, || {}) 2221 } 2222 2223 fn inventory_records_impl<F>( 2224 root: &Path, 2225 after_snapshot: F, 2226 ) -> Result<Vec<ArtifactRecord>, ReleaseArtifactError> 2227 where 2228 F: FnOnce(), 2229 { 2230 let snapshot = flat_directory_snapshot(root, ReleaseArtifactError::GenerationFailure)?; 2231 let names = snapshot_inventory(&snapshot, ReleaseArtifactError::GenerationFailure)?; 2232 after_snapshot(); 2233 if snapshot.root_permission_mode() != DIRECTORY_MODE { 2234 return Err(ReleaseArtifactError::GenerationFailure); 2235 } 2236 let mut records = Vec::with_capacity(names.len()); 2237 for name in names { 2238 let maximum = output_maximum(&name)?; 2239 let file = snapshot 2240 .files() 2241 .iter() 2242 .find(|file| file.relative_path() == Path::new(&name)) 2243 .ok_or(ReleaseArtifactError::GenerationFailure)?; 2244 if file.permission_mode() != FILE_MODE { 2245 return Err(ReleaseArtifactError::GenerationFailure); 2246 } 2247 let evidence = snapshot 2248 .hash(file, maximum) 2249 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2250 records.push(artifact_record( 2251 &name, 2252 &FileEvidence { 2253 byte_length: evidence.byte_length, 2254 sha256: evidence.sha256, 2255 }, 2256 )); 2257 } 2258 snapshot 2259 .revalidate() 2260 .map_err(|_| ReleaseArtifactError::GenerationFailure)?; 2261 Ok(records) 2262 } 2263 2264 fn artifact_record(path: &str, evidence: &FileEvidence) -> ArtifactRecord { 2265 ArtifactRecord { 2266 path: path.to_owned(), 2267 byte_length: evidence.byte_length, 2268 sha256: evidence.sha256.clone(), 2269 } 2270 } 2271 2272 fn artifact_record_from_exact_tree( 2273 path: &str, 2274 evidence: &exact_tree_archive::ExactTreeArchiveEvidence, 2275 ) -> ArtifactRecord { 2276 ArtifactRecord { 2277 path: path.to_owned(), 2278 byte_length: evidence.byte_length, 2279 sha256: evidence.sha256.clone(), 2280 } 2281 } 2282 2283 fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> { 2284 match name { 2285 "binary.tar.gz" => Ok(MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES), 2286 "oci-image.tar.gz" => Ok(MAX_OCI_BYTES), 2287 "service-source.tar" | "lib-source.tar" => Ok(MAX_SOURCE_ARCHIVE_BYTES), 2288 "LICENSE-APACHE" 2289 | "LICENSE-MIT" 2290 | "config.example.toml" 2291 | "config.schema.json" 2292 | "nixos-module.nix" 2293 | "radroots.service.source-lock.v3.toml" 2294 | "systemd.service" => Ok(MAX_TEXT_INPUT_BYTES), 2295 "SHA256SUMS" 2296 | "THIRD-PARTY-LICENSES.txt" 2297 | "THIRD-PARTY-NOTICES.txt" 2298 | "artifact-manifest.v2.json" 2299 | "artifact-scan.v1.json" 2300 | "oci-image.v1.json" 2301 | "provenance.intoto.jsonl" 2302 | "sbom.cdx.json" 2303 | "source-archives.v3.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES), 2304 _ => Err(ReleaseArtifactError::GenerationFailure), 2305 } 2306 } 2307 2308 fn hash_regular(path: &Path, maximum: u64) -> Result<FileEvidence, ReleaseArtifactError> { 2309 let evidence = safe_artifact_io::hash_regular_path(path, maximum) 2310 .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2311 Ok(FileEvidence { 2312 byte_length: evidence.byte_length, 2313 sha256: evidence.sha256, 2314 }) 2315 } 2316 2317 fn read_bounded_regular( 2318 path: &Path, 2319 maximum: u64, 2320 error: ReleaseArtifactError, 2321 ) -> Result<Vec<u8>, ReleaseArtifactError> { 2322 safe_artifact_io::read_regular_path(path, maximum).map_err(|_| error) 2323 } 2324 2325 #[cfg(test)] 2326 fn validate_regular_input(path: &Path, maximum: u64) -> Result<fs::Metadata, ReleaseArtifactError> { 2327 let metadata = 2328 fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2329 if metadata.file_type().is_symlink() 2330 || !metadata.is_file() 2331 || metadata.len() == 0 2332 || metadata.len() > maximum 2333 { 2334 Err(ReleaseArtifactError::InvalidInputArtifact) 2335 } else { 2336 Ok(metadata) 2337 } 2338 } 2339 2340 #[cfg(all(test, unix))] 2341 fn validate_unchanged_input( 2342 path: &Path, 2343 expected: &fs::Metadata, 2344 ) -> Result<(), ReleaseArtifactError> { 2345 use std::os::unix::fs::MetadataExt as _; 2346 let current = 2347 fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2348 if current.file_type().is_symlink() 2349 || !current.is_file() 2350 || current.dev() != expected.dev() 2351 || current.ino() != expected.ino() 2352 || current.len() != expected.len() 2353 { 2354 Err(ReleaseArtifactError::InvalidInputArtifact) 2355 } else { 2356 Ok(()) 2357 } 2358 } 2359 2360 #[cfg(all(test, not(unix)))] 2361 fn validate_unchanged_input( 2362 path: &Path, 2363 expected: &fs::Metadata, 2364 ) -> Result<(), ReleaseArtifactError> { 2365 let current = 2366 fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; 2367 if current.file_type().is_symlink() || !current.is_file() || current.len() != expected.len() { 2368 Err(ReleaseArtifactError::InvalidInputArtifact) 2369 } else { 2370 Ok(()) 2371 } 2372 } 2373 2374 #[cfg(test)] 2375 fn directory_inventory( 2376 root: &Path, 2377 error: ReleaseArtifactError, 2378 ) -> Result<BTreeSet<String>, ReleaseArtifactError> { 2379 let snapshot = flat_directory_snapshot(root, error)?; 2380 snapshot_inventory(&snapshot, error) 2381 } 2382 2383 fn flat_directory_snapshot( 2384 root: &Path, 2385 error: ReleaseArtifactError, 2386 ) -> Result<safe_artifact_io::TraversalSnapshot, ReleaseArtifactError> { 2387 let limits = TraversalLimits { 2388 max_entries: (OUTPUT_NAMES.len() + 1) as u64, 2389 max_files: (OUTPUT_NAMES.len() + 1) as u64, 2390 max_total_bytes: MAX_RELEASE_TREE_BYTES, 2391 max_file_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 2392 max_depth: 1, 2393 max_path_bytes: MAX_ARCHIVE_PATH_BYTES, 2394 }; 2395 safe_artifact_io::traverse_regular_files(root, limits, &[]).map_err(|_| error) 2396 } 2397 2398 fn snapshot_inventory( 2399 snapshot: &safe_artifact_io::TraversalSnapshot, 2400 error: ReleaseArtifactError, 2401 ) -> Result<BTreeSet<String>, ReleaseArtifactError> { 2402 if snapshot.entry_count() != snapshot.files().len() as u64 { 2403 return Err(error); 2404 } 2405 let mut names = BTreeSet::new(); 2406 for entry in snapshot.files() { 2407 let path = entry.relative_path(); 2408 if path.components().count() != 1 { 2409 return Err(error); 2410 } 2411 let name = path 2412 .file_name() 2413 .and_then(OsStr::to_str) 2414 .ok_or(error)? 2415 .to_owned(); 2416 if !names.insert(name) || names.len() > OUTPUT_NAMES.len() { 2417 return Err(error); 2418 } 2419 } 2420 snapshot.revalidate().map_err(|_| error)?; 2421 Ok(names) 2422 } 2423 2424 fn validate_exact_output_inventory(root: &Path) -> Result<(), ReleaseArtifactError> { 2425 let expected = OUTPUT_NAMES.into_iter().map(str::to_owned).collect(); 2426 let actual = inventory_records(root)? 2427 .into_iter() 2428 .map(|record| record.path) 2429 .collect::<BTreeSet<_>>(); 2430 if actual != expected { 2431 return Err(ReleaseArtifactError::GenerationFailure); 2432 } 2433 Ok(()) 2434 } 2435 2436 fn compare_output(expected: &Path, actual: &Path) -> Result<(), ReleaseArtifactError> { 2437 let actual = validate_absolute_directory(actual, ReleaseArtifactError::StaleOutput)?; 2438 let expected_records = 2439 inventory_records(expected).map_err(|_| ReleaseArtifactError::StaleOutput)?; 2440 let actual_records = 2441 inventory_records(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; 2442 if expected_records != actual_records { 2443 return Err(ReleaseArtifactError::StaleOutput); 2444 } 2445 Ok(()) 2446 } 2447 2448 fn validate_output_records( 2449 actual: &Path, 2450 expected: &[ArtifactRecord], 2451 ) -> Result<(), ReleaseArtifactError> { 2452 let actual = validate_absolute_directory(actual, ReleaseArtifactError::StaleOutput)?; 2453 validate_exact_output_inventory(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; 2454 let actual_records = 2455 inventory_records(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; 2456 if actual_records == expected { 2457 Ok(()) 2458 } else { 2459 Err(ReleaseArtifactError::StaleOutput) 2460 } 2461 } 2462 2463 #[cfg(unix)] 2464 fn set_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> { 2465 use std::os::unix::fs::PermissionsExt as _; 2466 fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)) 2467 .map_err(|_| ReleaseArtifactError::GenerationFailure) 2468 } 2469 2470 #[cfg(not(unix))] 2471 fn set_file_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { 2472 Ok(()) 2473 } 2474 2475 #[cfg(unix)] 2476 fn set_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> { 2477 use std::os::unix::fs::PermissionsExt as _; 2478 fs::set_permissions(path, fs::Permissions::from_mode(DIRECTORY_MODE)) 2479 .map_err(|_| ReleaseArtifactError::GenerationFailure) 2480 } 2481 2482 #[cfg(not(unix))] 2483 fn set_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { 2484 Ok(()) 2485 } 2486 2487 #[cfg(all(test, unix))] 2488 fn validate_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> { 2489 use std::os::unix::fs::PermissionsExt as _; 2490 let mode = fs::symlink_metadata(path) 2491 .map_err(|_| ReleaseArtifactError::StaleOutput)? 2492 .permissions() 2493 .mode() 2494 & 0o7777; 2495 if mode == FILE_MODE { 2496 Ok(()) 2497 } else { 2498 Err(ReleaseArtifactError::StaleOutput) 2499 } 2500 } 2501 2502 #[cfg(all(test, not(unix)))] 2503 fn validate_file_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { 2504 Ok(()) 2505 } 2506 2507 #[cfg(all(test, unix))] 2508 fn validate_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> { 2509 use std::os::unix::fs::PermissionsExt as _; 2510 let mode = fs::symlink_metadata(path) 2511 .map_err(|_| ReleaseArtifactError::StaleOutput)? 2512 .permissions() 2513 .mode() 2514 & 0o7777; 2515 if mode == DIRECTORY_MODE { 2516 Ok(()) 2517 } else { 2518 Err(ReleaseArtifactError::StaleOutput) 2519 } 2520 } 2521 2522 #[cfg(all(test, not(unix)))] 2523 fn validate_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { 2524 Ok(()) 2525 } 2526 2527 fn sync_directory(path: &Path) -> Result<(), ReleaseArtifactError> { 2528 fs::File::open(path) 2529 .and_then(|directory| directory.sync_all()) 2530 .map_err(|_| ReleaseArtifactError::GenerationFailure) 2531 } 2532 2533 fn command_stdout(command: &mut Command, maximum: usize) -> Result<Vec<u8>, ()> { 2534 let mut child = command 2535 .stdin(Stdio::null()) 2536 .stdout(Stdio::piped()) 2537 .stderr(Stdio::null()) 2538 .spawn() 2539 .map_err(|_| ())?; 2540 let mut stdout = child.stdout.take().ok_or(())?; 2541 let mut bytes = Vec::new(); 2542 if stdout 2543 .by_ref() 2544 .take(maximum as u64 + 1) 2545 .read_to_end(&mut bytes) 2546 .is_err() 2547 || bytes.len() > maximum 2548 { 2549 let _ = child.kill(); 2550 let _ = child.wait(); 2551 return Err(()); 2552 } 2553 let status = child.wait().map_err(|_| ())?; 2554 if status.success() { Ok(bytes) } else { Err(()) } 2555 } 2556 2557 fn git_stdout<const N: usize>(root: &Path, args: [&str; N], maximum: usize) -> Result<Vec<u8>, ()> { 2558 let mut command = Command::new("git"); 2559 command.args(args).current_dir(root); 2560 command_stdout(&mut command, maximum) 2561 } 2562 2563 fn valid_metadata_text(value: &str) -> bool { 2564 !value.is_empty() 2565 && value.len() <= MAX_TEXT_FIELD_BYTES 2566 && !value.contains(['\n', '\r']) 2567 && !SECRET_PATTERNS.iter().any(|pattern| { 2568 value 2569 .as_bytes() 2570 .windows(pattern.len()) 2571 .any(|window| window == *pattern) 2572 }) 2573 && scan_bytes(value.as_bytes()).is_ok() 2574 } 2575 2576 fn valid_public_source(value: &str) -> bool { 2577 valid_metadata_text(value) 2578 && (value.starts_with("registry+https://") || value.starts_with("git+https://")) 2579 && !value.contains("@github.com") 2580 } 2581 2582 fn valid_snake_identifier(value: &str) -> bool { 2583 value.len() <= 128 2584 && value 2585 .split('_') 2586 .all(|segment| !segment.is_empty() && valid_lower_alphanumeric(segment)) 2587 && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) 2588 && value 2589 .as_bytes() 2590 .last() 2591 .is_some_and(u8::is_ascii_alphanumeric) 2592 } 2593 2594 fn valid_kebab_identifier(value: &str) -> bool { 2595 value.len() <= 128 2596 && value 2597 .split('-') 2598 .all(|segment| !segment.is_empty() && valid_lower_alphanumeric(segment)) 2599 && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) 2600 && value 2601 .as_bytes() 2602 .last() 2603 .is_some_and(u8::is_ascii_alphanumeric) 2604 } 2605 2606 fn valid_lower_alphanumeric(value: &str) -> bool { 2607 value 2608 .bytes() 2609 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) 2610 } 2611 2612 fn valid_output_component(value: &str) -> bool { 2613 let mut components = Path::new(value).components(); 2614 !value.is_empty() 2615 && value.len() <= 128 2616 && !value.contains(['/', '\\']) 2617 && matches!(components.next(), Some(Component::Normal(_))) 2618 && components.next().is_none() 2619 && value != "." 2620 && value != ".." 2621 } 2622 2623 fn valid_lower_hex(value: &str, length: usize) -> bool { 2624 value.len() == length 2625 && value 2626 .bytes() 2627 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 2628 } 2629 2630 fn sha256_bytes(bytes: &[u8]) -> String { 2631 hex::encode(Sha256::digest(bytes)) 2632 } 2633 2634 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { 2635 validate_contract_inner(workspace_root).map_err(|error| error.to_string()) 2636 } 2637 2638 fn validate_contract_inner(workspace_root: &Path) -> Result<(), ReleaseArtifactError> { 2639 let bytes = read_bounded_regular( 2640 &workspace_root.join(CONTRACT_RELATIVE), 2641 MAX_CONTRACT_BYTES as u64, 2642 ReleaseArtifactError::InvalidContract, 2643 )?; 2644 let decision = serde_json::from_slice::<ReleaseDecision>(&bytes) 2645 .map_err(|_| ReleaseArtifactError::InvalidContract)?; 2646 validate_decision(&decision) 2647 } 2648 2649 fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactError> { 2650 let errors = [ 2651 ReleaseArtifactError::InvalidContract, 2652 ReleaseArtifactError::InvalidServiceRoot, 2653 ReleaseArtifactError::DirtyServiceSource, 2654 ReleaseArtifactError::InvalidServiceMetadata, 2655 ReleaseArtifactError::InvalidInputRoot, 2656 ReleaseArtifactError::InvalidInputArtifact, 2657 ReleaseArtifactError::InvalidSourceLock, 2658 ReleaseArtifactError::InvalidSourceBundle, 2659 ReleaseArtifactError::InvalidPackageInventory, 2660 ReleaseArtifactError::ProtectedMaterialDetected, 2661 ReleaseArtifactError::InvalidOutputRoot, 2662 ReleaseArtifactError::StaleOutput, 2663 ReleaseArtifactError::GenerationFailure, 2664 ]; 2665 if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v4" 2666 || decision.contract_version != 4 2667 || decision.decision_state != "active" 2668 || decision.owner_step != 305 2669 || decision.predecessor.schema 2670 != "radroots.services-hardening.release-artifacts-decisions.v3" 2671 || decision.predecessor.filename != "services_hardening_release_artifacts.v3.json" 2672 || decision.predecessor.transition != "forward_only_replace" 2673 || decision.command != "cargo xtask service-release-artifacts" 2674 || decision.modes != ["check", "write"] 2675 || decision.required_arguments 2676 != [ 2677 "mode", 2678 "service_root", 2679 "lib_root", 2680 "input_root", 2681 "output_root", 2682 "target", 2683 "source_date_epoch", 2684 "candidate_digest", 2685 ] 2686 || decision.service_metadata_path 2687 != "Cargo.toml.workspace.metadata.radroots.service_release" 2688 || decision.service_metadata_fields 2689 != ["service", "service_package", "binary_name", "version"] 2690 || decision.service_license_path 2691 != "Cargo.toml.workspace.package.license_or_package.license" 2692 || decision.source_lock_schema != "radroots.service.source-lock.v3" 2693 || decision.source_lock_definition 2694 != "contracts/architecture/decisions/services_hardening_source_lock.v3.json" 2695 || decision.artifact_contract_binding 2696 != "source_lock_exact_regular_file_bytes_in_same_service_revision" 2697 || decision.artifact_admission_contract 2698 != "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json" 2699 || decision.supported_targets != SUPPORTED_TARGETS 2700 || decision.candidate_binding != "explicit_sha256_candidate_identity_digest" 2701 || decision.lib_root_binding 2702 != "canonical_public_lib_git_root_containing_the_locked_revision" 2703 || decision.binary_admission 2704 != "exact_format_architecture_linkage_structural_and_native_bounded_help_smoke" 2705 || decision.oci_admission 2706 != "safe_materialization_exact_manifest_config_AGPL_labels_layers_and_entrypoint" 2707 || decision.input_inventory != INPUT_NAMES 2708 || decision.excluded_parent_owned_inputs != ["backup_restore_runbook", "operator_runbook"] 2709 || decision.service_root_inventory != ["LICENSE-APACHE", "LICENSE-MIT", LOCK_FILENAME] 2710 || decision.output_inventory != OUTPUT_NAMES 2711 || decision.canonical_json != "compact_utf8_json_with_one_final_lf" 2712 || decision.checksum_format != "sha256_lower_hex_two_spaces_path_lf_sorted_by_path" 2713 || decision.source_archive_format 2714 != "canonical_uncompressed_ustar_exact_git_revision_tree_without_history" 2715 || decision.sbom_format != "cyclonedx_json_1_6_complete_cargo_nix_and_artifact_closure" 2716 || decision.license_evidence != "exact_dependency_attribution_with_bounded_license_texts" 2717 || decision.provenance_posture 2718 != "candidate_derived_unsigned_intoto_statement_slsa_v1_exact_manifest_subjects" 2719 || decision.protected_material_scan_scope 2720 != "all_artifact_bytes_and_bounded_nested_binary_oci_layer_and_source_archive_payloads" 2721 || decision.confidentiality_state != "derived_only_from_the_exact_artifact_scan_record" 2722 || decision.source_cleanliness != "no_tracked_staged_or_untracked_changes" 2723 || decision.revision_stability != "same_service_head_before_and_after_generation" 2724 || !decision.no_protected_material 2725 || decision.maximums.text_input_bytes != MAX_TEXT_INPUT_BYTES 2726 || decision.maximums.generated_document_bytes != MAX_GENERATED_DOCUMENT_BYTES 2727 || decision.maximums.service_cargo_lock_bytes != MAX_SERVICE_CARGO_LOCK_BYTES 2728 || decision.maximums.service_flake_lock_bytes != MAX_SERVICE_FLAKE_LOCK_BYTES 2729 || decision.maximums.binary_bytes != MAX_BINARY_BYTES 2730 || decision.maximums.source_archive_bytes != MAX_SOURCE_ARCHIVE_BYTES 2731 || decision.maximums.source_archive_member_bytes != MAX_SOURCE_ARCHIVE_MEMBER_BYTES 2732 || decision.maximums.source_archive_members != MAX_SOURCE_ARCHIVE_MEMBERS 2733 || decision.maximums.oci_bytes != MAX_OCI_BYTES 2734 || decision.maximums.artifact_scan_expanded_bytes != MAX_ARCHIVE_EXPANDED_BYTES 2735 || decision.maximums.cargo_metadata_bytes != MAX_METADATA_BYTES 2736 || decision.maximums.packages != MAX_PACKAGES 2737 || decision.maximums.workspace_packages != MAX_WORKSPACE_PACKAGES 2738 || decision.required_negative_vectors 2739 != [ 2740 "cyclonedx_schema_drift", 2741 "missing_dependency_component", 2742 "unreconciled_artifact_subject", 2743 "missing_or_mismatched_license_text", 2744 "invented_candidate_digest", 2745 "git_history_bundle", 2746 "secret_in_binary", 2747 "secret_in_oci_layer", 2748 "secret_in_source_archive", 2749 "sensitive_archive_path", 2750 "scan_confidentiality_mismatch", 2751 ] 2752 || decision.negative_error_codes != errors.map(ReleaseArtifactError::code) 2753 { 2754 return Err(ReleaseArtifactError::InvalidContract); 2755 } 2756 Ok(()) 2757 } 2758 2759 #[cfg(test)] 2760 mod tests { 2761 use serde_json::{Value, json}; 2762 use std::process::Command; 2763 2764 use crate::service_source_lock::ContractVersions; 2765 use crate::service_source_lock_v3::FixtureParts; 2766 2767 use super::*; 2768 2769 struct ReleaseFixture { 2770 _root: TempDir, 2771 service: PathBuf, 2772 lib: PathBuf, 2773 input: PathBuf, 2774 output_a: PathBuf, 2775 output_b: PathBuf, 2776 } 2777 2778 impl ReleaseFixture { 2779 fn new() -> Self { 2780 let root = TempDir::new().expect("fixture root"); 2781 let canonical_root = root.path().canonicalize().expect("canonical fixture root"); 2782 let service = canonical_root.join("service"); 2783 let lib = canonical_root.join("lib"); 2784 let input = canonical_root.join("input"); 2785 fs::create_dir_all(service.join("src")).expect("service source"); 2786 fs::create_dir_all(lib.join("contracts/crates")).expect("Lib contracts"); 2787 fs::create_dir(&input).expect("input root"); 2788 2789 write_file( 2790 &lib.join("contracts/crates/catalog.v2.toml"), 2791 b"schema_version = 2\n", 2792 ); 2793 write_file(&lib.join("README.md"), b"fixture Lib source\n"); 2794 initialize_git(&lib, "https://github.com/radrootslabs/lib"); 2795 let lib_revision = git_output(&lib, &["rev-parse", "HEAD"]); 2796 let lib_archive_path = canonical_root.join("lib-source.tar"); 2797 let lib_archive = exact_tree_archive::create( 2798 &lib, 2799 &lib_revision, 2800 &lib_archive_path, 2801 exact_tree_archive::commit_timestamp(&lib, &lib_revision) 2802 .expect("Lib commit timestamp"), 2803 ) 2804 .expect("Lib source archive"); 2805 let catalog = 2806 fs::read(lib.join("contracts/crates/catalog.v2.toml")).expect("workspace catalog"); 2807 2808 write_file( 2809 &service.join("Cargo.toml"), 2810 br#"[package] 2811 name = "fixture-service" 2812 version = "0.1.0-alpha" 2813 edition = "2024" 2814 license = "AGPL-3.0-or-later" 2815 2816 [[bin]] 2817 name = "fixture-service" 2818 path = "src/main.rs" 2819 2820 [workspace] 2821 resolver = "3" 2822 2823 [workspace.metadata.radroots.service_release] 2824 service = "myc" 2825 service_package = "fixture-service" 2826 binary_name = "fixture-service" 2827 version = "0.1.0-alpha" 2828 "#, 2829 ); 2830 write_file(&service.join("src/main.rs"), b"fn main() {}\n"); 2831 write_file( 2832 &service.join("Cargo.lock"), 2833 br#"# This file is automatically @generated by Cargo. 2834 # It is not intended for manual editing. 2835 version = 4 2836 2837 [[package]] 2838 name = "fixture-service" 2839 version = "0.1.0-alpha" 2840 "#, 2841 ); 2842 write_file( 2843 &service.join("flake.nix"), 2844 format!( 2845 "{{\n inputs.lib = {{\n url = \"github:radrootslabs/lib/{lib_revision}\";\n flake = false;\n }};\n outputs = {{ ... }}: {{ }};\n}}\n" 2846 ) 2847 .as_bytes(), 2848 ); 2849 write_file( 2850 &service.join("flake.lock"), 2851 format!( 2852 "{{\"nodes\":{{\"root\":{{\"inputs\":{{\"lib\":\"lib\"}}}},\"lib\":{{\"locked\":{{\"lastModified\":1,\"narHash\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"owner\":\"radrootslabs\",\"repo\":\"lib\",\"rev\":\"{lib_revision}\",\"type\":\"github\"}},\"original\":{{\"owner\":\"radrootslabs\",\"repo\":\"lib\",\"rev\":\"{lib_revision}\",\"type\":\"github\"}}}}}},\"root\":\"root\",\"version\":7}}\n" 2853 ) 2854 .as_bytes(), 2855 ); 2856 write_file( 2857 &service.join("LICENSE-APACHE"), 2858 b"Apache-2.0 fixture license\n", 2859 ); 2860 write_file(&service.join("LICENSE-MIT"), b"MIT fixture license\n"); 2861 let artifact_contract_path = 2862 service.join("contracts/release/myc-artifact-contract.v3.json"); 2863 fs::create_dir_all( 2864 artifact_contract_path 2865 .parent() 2866 .expect("artifact contract parent"), 2867 ) 2868 .expect("artifact contract directory"); 2869 write_file(&artifact_contract_path, b"{}\n"); 2870 let cargo_lock = fs::read(service.join("Cargo.lock")).expect("Cargo lock"); 2871 let flake_lock = fs::read(service.join("flake.lock")).expect("flake lock"); 2872 let artifact_contract = fs::read(&artifact_contract_path).expect("artifact contract"); 2873 let source_lock = ServiceSourceLockV3::fixture(FixtureParts { 2874 service: "myc", 2875 revision: &lib_revision, 2876 workspace_catalog_sha256: &sha256_bytes(&catalog), 2877 source_archive_sha256: &lib_archive.sha256, 2878 cargo_lock_sha256: &sha256_bytes(&cargo_lock), 2879 flake_lock_sha256: &sha256_bytes(&flake_lock), 2880 artifact_contract_sha256: &sha256_bytes(&artifact_contract), 2881 contract_versions: ContractVersions::new(1, 1, 1, 1, 1), 2882 }) 2883 .expect("source lock"); 2884 write_file(&service.join(LOCK_FILENAME), source_lock.canonical_bytes()); 2885 initialize_git(&service, "https://github.com/radrootslabs/fixture-service"); 2886 let service_revision = git_output(&service, &["rev-parse", "HEAD"]); 2887 for (name, bytes) in [ 2888 ("config.example.toml", b"enabled = true\n".as_slice()), 2889 ("config.schema.json", b"{\"type\":\"object\"}\n".as_slice()), 2890 ("nixos-module.nix", b"{ ... }: {}\n".as_slice()), 2891 ( 2892 "systemd.service", 2893 b"[Service]\nExecStart=/usr/bin/fixture-service\n".as_slice(), 2894 ), 2895 ] { 2896 write_file(&input.join(name), bytes); 2897 } 2898 let status = Command::new("rustc") 2899 .args(["--edition=2024", "src/main.rs", "-o"]) 2900 .arg(input.join("service-binary")) 2901 .current_dir(&service) 2902 .status() 2903 .expect("compile fixture service binary"); 2904 assert!(status.success(), "compile fixture service binary"); 2905 create_oci_fixture( 2906 &input.join("oci-image.tar.gz"), 2907 &service_revision, 2908 &lib_revision, 2909 ); 2910 2911 Self { 2912 output_a: canonical_root.join("release-a"), 2913 output_b: canonical_root.join("release-b"), 2914 _root: root, 2915 service, 2916 lib, 2917 input, 2918 } 2919 } 2920 2921 fn write(&self, output: &Path) -> Result<(), ReleaseArtifactError> { 2922 run_inner(Arguments { 2923 mode: CommandMode::Write, 2924 service_root: &self.service, 2925 lib_root: &self.lib, 2926 input_root: &self.input, 2927 output_root: output, 2928 target: native_fixture_target(), 2929 source_date_epoch: 1_700_000_000, 2930 candidate_digest: &"a".repeat(64), 2931 }) 2932 } 2933 2934 fn check(&self, output: &Path) -> Result<(), ReleaseArtifactError> { 2935 run_inner(Arguments { 2936 mode: CommandMode::Check, 2937 service_root: &self.service, 2938 lib_root: &self.lib, 2939 input_root: &self.input, 2940 output_root: output, 2941 target: native_fixture_target(), 2942 source_date_epoch: 1_700_000_000, 2943 candidate_digest: &"a".repeat(64), 2944 }) 2945 } 2946 } 2947 2948 fn write_file(path: &Path, bytes: &[u8]) { 2949 fs::write(path, bytes).expect("write fixture file"); 2950 } 2951 2952 fn git(root: &Path, args: &[&str]) { 2953 let status = Command::new("git") 2954 .args(args) 2955 .current_dir(root) 2956 .status() 2957 .expect("run Git"); 2958 assert!(status.success(), "git {args:?}"); 2959 } 2960 2961 fn git_output(root: &Path, args: &[&str]) -> String { 2962 let output = Command::new("git") 2963 .args(args) 2964 .current_dir(root) 2965 .output() 2966 .expect("run Git"); 2967 assert!(output.status.success(), "git {args:?}"); 2968 String::from_utf8(output.stdout) 2969 .expect("Git UTF-8") 2970 .trim() 2971 .to_owned() 2972 } 2973 2974 fn initialize_git(root: &Path, remote: &str) { 2975 git(root, &["init", "--quiet"]); 2976 git(root, &["config", "user.email", "fixture@radroots.test"]); 2977 git(root, &["config", "user.name", "Radroots Fixture"]); 2978 git(root, &["remote", "add", "origin", remote]); 2979 git(root, &["add", "."]); 2980 git(root, &["commit", "--quiet", "-m", "fixture"]); 2981 git(root, &["branch", "-M", "archive"]); 2982 } 2983 2984 fn native_fixture_target() -> &'static str { 2985 if cfg!(all(target_os = "macos", target_arch = "aarch64")) { 2986 "aarch64-apple-darwin" 2987 } else { 2988 "x86_64-unknown-linux-gnu" 2989 } 2990 } 2991 2992 fn create_oci_fixture(output: &Path, service_revision: &str, lib_revision: &str) { 2993 let entrypoint = 2994 "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-fixture-service/bin/fixture-service"; 2995 let mut layer = Vec::new(); 2996 { 2997 let mut tar = TarBuilder::new(&mut layer); 2998 let bytes = b"fixture executable"; 2999 let mut header = TarHeader::new_gnu(); 3000 header.set_size(bytes.len() as u64); 3001 header.set_mode(0o755); 3002 header.set_uid(0); 3003 header.set_gid(0); 3004 header.set_mtime(0); 3005 header.set_cksum(); 3006 tar.append_data( 3007 &mut header, 3008 entrypoint.trim_start_matches('/'), 3009 bytes.as_slice(), 3010 ) 3011 .expect("write layer entrypoint"); 3012 tar.finish().expect("finish layer"); 3013 } 3014 let layer_digest = sha256_bytes(&layer); 3015 let layer_name = format!("{layer_digest}/layer.tar"); 3016 let labels = artifact_admission::OciExpectation { 3017 service: "myc", 3018 binary_name: "fixture-service", 3019 version: "0.1.0-alpha", 3020 service_revision, 3021 lib_revision, 3022 license: "AGPL-3.0-or-later", 3023 contract_versions: artifact_admission::ContractVersions { 3024 admin: 1, 3025 config: 1, 3026 provider: 1, 3027 state: 1, 3028 status: 1, 3029 }, 3030 }; 3031 let labels = artifact_admission::fixture_labels(&labels); 3032 let config = serde_json::to_vec(&serde_json::json!({ 3033 "architecture": "amd64", 3034 "config": { 3035 "Entrypoint": [entrypoint], 3036 "Env": ["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"], 3037 "Labels": labels, 3038 "StopSignal": "SIGTERM", 3039 "User": "65532:65532", 3040 "WorkingDir": "/" 3041 }, 3042 "created": "1970-01-01T00:00:01+00:00", 3043 "os": "linux", 3044 "rootfs": {"diff_ids": [format!("sha256:{layer_digest}")], "type": "layers"} 3045 })) 3046 .expect("serialize config"); 3047 let config_name = format!("{}.json", sha256_bytes(&config)); 3048 let manifest = serde_json::to_vec(&serde_json::json!([{ 3049 "Config": config_name, 3050 "Layers": [layer_name], 3051 "RepoTags": ["myc:0.1.0-alpha"] 3052 }])) 3053 .expect("serialize manifest"); 3054 let repositories = serde_json::to_vec(&serde_json::json!({ 3055 "myc": {"0.1.0-alpha": layer_digest} 3056 })) 3057 .expect("serialize repositories"); 3058 let mut members = vec![ 3059 (config_name, false, config), 3060 (format!("{layer_digest}/"), true, Vec::new()), 3061 (format!("{layer_digest}/VERSION"), false, b"1.0".to_vec()), 3062 (format!("{layer_digest}/json"), false, b"{}".to_vec()), 3063 (layer_name, false, layer), 3064 ("manifest.json".to_owned(), false, manifest), 3065 ("repositories".to_owned(), false, repositories), 3066 ]; 3067 members.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); 3068 let output_file = fs::File::create(output).expect("create OCI fixture"); 3069 let encoder = GzBuilder::new() 3070 .mtime(0) 3071 .operating_system(255) 3072 .write(output_file, Compression::best()); 3073 let mut archive = TarBuilder::new(encoder); 3074 for (name, directory, bytes) in members { 3075 let mut header = TarHeader::new_gnu(); 3076 header.set_entry_type(if directory { 3077 tar::EntryType::Directory 3078 } else { 3079 tar::EntryType::Regular 3080 }); 3081 header.set_size(bytes.len() as u64); 3082 header.set_mode(if directory { 0o755 } else { 0o644 }); 3083 header.set_uid(0); 3084 header.set_gid(0); 3085 header.set_mtime(0); 3086 header.set_cksum(); 3087 archive 3088 .append_data(&mut header, name, bytes.as_slice()) 3089 .expect("write OCI member"); 3090 } 3091 let encoder = archive.into_inner().expect("finish OCI fixture tar"); 3092 let file = encoder.finish().expect("finish OCI fixture gzip"); 3093 file.sync_all().expect("sync OCI fixture"); 3094 } 3095 3096 fn sample_metadata() -> ReleaseMetadata { 3097 ReleaseMetadata { 3098 service: "fixture_service".to_owned(), 3099 service_package: "fixture-service".to_owned(), 3100 binary_name: "fixture-service".to_owned(), 3101 version: "0.1.0-alpha".to_owned(), 3102 license: "AGPL-3.0-or-later".to_owned(), 3103 } 3104 } 3105 3106 fn package( 3107 id: &str, 3108 name: &str, 3109 source: Option<&str>, 3110 checksum: Option<&str>, 3111 license: Option<&str>, 3112 binary: bool, 3113 ) -> CargoPackage { 3114 CargoPackage { 3115 id: id.to_owned(), 3116 name: name.to_owned(), 3117 version: "0.1.0-alpha".to_owned(), 3118 source: source.map(str::to_owned), 3119 checksum: checksum.map(str::to_owned), 3120 license: license.map(str::to_owned), 3121 manifest_path: format!("/fixture/{name}/Cargo.toml"), 3122 license_file: None, 3123 targets: if binary { 3124 vec![CargoTarget { 3125 name: name.to_owned(), 3126 kind: vec!["bin".to_owned()], 3127 }] 3128 } else { 3129 Vec::new() 3130 }, 3131 license_texts: source 3132 .map(|_| { 3133 let text = "fixture dependency license text\n".to_owned(); 3134 vec![DependencyLicenseText { 3135 filename: "LICENSE".to_owned(), 3136 sha256: sha256_bytes(text.as_bytes()), 3137 text, 3138 }] 3139 }) 3140 .unwrap_or_default(), 3141 } 3142 } 3143 3144 fn sample_cargo_metadata() -> CargoMetadata { 3145 let root_id = "path+file:///fixture#fixture-service@0.1.0-alpha"; 3146 let dependency_id = "registry+https://example.invalid#index@0.1.0-alpha"; 3147 CargoMetadata { 3148 packages: vec![ 3149 package(root_id, "fixture-service", None, None, Some("MIT"), true), 3150 package( 3151 dependency_id, 3152 "dependency", 3153 Some("registry+https://github.com/rust-lang/crates.io-index"), 3154 Some(&"a".repeat(64)), 3155 Some("Apache-2.0"), 3156 false, 3157 ), 3158 ], 3159 workspace_members: vec![root_id.to_owned()], 3160 resolve: Some(CargoResolve { 3161 nodes: vec![ 3162 CargoNode { 3163 id: dependency_id.to_owned(), 3164 dependencies: Vec::new(), 3165 }, 3166 CargoNode { 3167 id: root_id.to_owned(), 3168 dependencies: vec![dependency_id.to_owned()], 3169 }, 3170 ], 3171 }), 3172 } 3173 } 3174 3175 #[test] 3176 fn oci_admission_reconciles_manifest_config_and_layer_content() { 3177 let temporary = TempDir::new().unwrap(); 3178 let root = temporary.path().canonicalize().unwrap(); 3179 let original = root.join("original.tar.gz"); 3180 let revision = "a".repeat(40); 3181 create_oci_fixture(&original, &revision, &revision); 3182 let expected = artifact_admission::OciExpectation { 3183 service: "myc", 3184 binary_name: "fixture-service", 3185 version: "0.1.0-alpha", 3186 service_revision: &revision, 3187 lib_revision: &revision, 3188 license: "AGPL-3.0-or-later", 3189 contract_versions: artifact_admission::ContractVersions { 3190 admin: 1, 3191 config: 1, 3192 provider: 1, 3193 state: 1, 3194 status: 1, 3195 }, 3196 }; 3197 artifact_admission::admit_oci(&original, &root, &expected).unwrap(); 3198 let decoder = flate2::read::GzDecoder::new(fs::File::open(&original).unwrap()); 3199 let mut archive = tar::Archive::new(decoder); 3200 let mut original_members = BTreeMap::new(); 3201 let mut directories = BTreeSet::new(); 3202 for entry in archive.entries().unwrap() { 3203 let mut entry = entry.unwrap(); 3204 let name = entry.path().unwrap().to_str().unwrap().to_owned(); 3205 if entry.header().entry_type().is_dir() { 3206 directories.insert(name.clone()); 3207 } 3208 let mut bytes = Vec::new(); 3209 entry.read_to_end(&mut bytes).unwrap(); 3210 original_members.insert(name, bytes); 3211 } 3212 for case in 0..14 { 3213 let mut members = original_members.clone(); 3214 let mut manifest: Value = serde_json::from_slice(&members["manifest.json"]).unwrap(); 3215 match case { 3216 0 => manifest = json!([]), 3217 1 => manifest[0]["Config"] = json!("wrong.extension"), 3218 2 => manifest[0]["Config"] = json!("invalid.json"), 3219 3 => manifest[0]["RepoTags"] = json!(["wrong:tag"]), 3220 4 => manifest[0]["Layers"] = json!([]), 3221 5 => manifest[0]["Layers"] = json!(["one", "two", "three"]), 3222 6 => { 3223 manifest[0]["Layers"] = json!([ 3224 manifest[0]["Layers"][0].clone(), 3225 manifest[0]["Layers"][0].clone() 3226 ]) 3227 } 3228 7 => { 3229 members 3230 .get_mut(manifest[0]["Config"].as_str().unwrap()) 3231 .unwrap() 3232 .push(b' '); 3233 } 3234 8..=10 => { 3235 let old_name = manifest[0]["Config"].as_str().unwrap().to_owned(); 3236 let mut config: Value = 3237 serde_json::from_slice(&members.remove(&old_name).unwrap()).unwrap(); 3238 match case { 3239 8 => config["rootfs"]["type"] = json!("unknown"), 3240 9 => config["rootfs"]["diff_ids"] = json!([]), 3241 _ => { 3242 config["rootfs"]["diff_ids"] = 3243 json!([format!("sha256:{}", "0".repeat(64))]) 3244 } 3245 } 3246 let bytes = serde_json::to_vec(&config).unwrap(); 3247 let name = format!("{}.json", sha256_bytes(&bytes)); 3248 members.insert(name.clone(), bytes); 3249 manifest[0]["Config"] = json!(name); 3250 } 3251 11 => { 3252 members.remove(manifest[0]["Layers"][0].as_str().unwrap()); 3253 } 3254 12 => { 3255 members.insert("unexpected".to_owned(), b"extra".to_vec()); 3256 } 3257 _ => {} 3258 } 3259 members.insert( 3260 "manifest.json".to_owned(), 3261 serde_json::to_vec(&manifest).unwrap(), 3262 ); 3263 let path = root.join(format!("invalid-{case}.tar.gz")); 3264 let encoder = GzBuilder::new() 3265 .mtime(0) 3266 .operating_system(255) 3267 .write(fs::File::create(&path).unwrap(), Compression::best()); 3268 let mut archive = TarBuilder::new(encoder); 3269 for (name, bytes) in members { 3270 let mut header = TarHeader::new_gnu(); 3271 let directory = directories.contains(&name); 3272 header.set_entry_type(if directory { 3273 tar::EntryType::Directory 3274 } else { 3275 tar::EntryType::Regular 3276 }); 3277 header.set_size(bytes.len() as u64); 3278 header.set_mode(if directory { 0o755 } else { 0o644 }); 3279 header.set_uid(0); 3280 header.set_gid(0); 3281 header.set_mtime(0); 3282 header.set_cksum(); 3283 archive 3284 .append_data(&mut header, name, bytes.as_slice()) 3285 .unwrap(); 3286 } 3287 archive.into_inner().unwrap().finish().unwrap(); 3288 let admitted = artifact_admission::admit_oci(&path, &root, &expected); 3289 if case == 13 { 3290 admitted.expect("rewritten unmodified OCI fixture remains valid"); 3291 } else { 3292 assert!(admitted.is_err(), "OCI case {case}"); 3293 } 3294 } 3295 for case in 0..5 { 3296 let mut changed = artifact_admission::OciExpectation { ..expected }; 3297 match case { 3298 0 => changed.license = "MIT", 3299 1 => changed.service = "../escape", 3300 2 => changed.binary_name = "../escape", 3301 3 => changed.service_revision = "invalid", 3302 _ => changed.lib_revision = "invalid", 3303 } 3304 assert!(artifact_admission::admit_oci(&original, &root, &changed).is_err()); 3305 } 3306 } 3307 3308 #[test] 3309 fn dependency_license_admission_preserves_exact_text_and_rejects_missing_or_unsafe_paths() { 3310 let temporary = TempDir::new().unwrap(); 3311 let root = temporary.path().canonicalize().unwrap(); 3312 let mut dependency = package( 3313 "fixture", 3314 "fixture", 3315 Some("registry+https://github.com/rust-lang/crates.io-index"), 3316 Some(&"a".repeat(64)), 3317 Some("MIT"), 3318 false, 3319 ); 3320 dependency.manifest_path = root.join("Cargo.toml").to_str().unwrap().to_owned(); 3321 fs::write(root.join("Cargo.toml"), b"fixture").unwrap(); 3322 assert!(dependency_license_texts(&dependency).is_err()); 3323 let text = "Fixture copyright\nPermission is granted.\n"; 3324 fs::write(root.join("LICENSE"), text).unwrap(); 3325 fs::write(root.join("COPYING-MIT"), "Copying terms\n").unwrap(); 3326 fs::write(root.join("NOTICE.txt"), "Notice terms\n").unwrap(); 3327 let texts = dependency_license_texts(&dependency).unwrap(); 3328 assert_eq!( 3329 texts 3330 .iter() 3331 .map(|row| row.filename.as_str()) 3332 .collect::<Vec<_>>(), 3333 ["COPYING-MIT", "LICENSE", "NOTICE.txt"] 3334 ); 3335 assert_eq!(texts[1].text, text); 3336 assert_eq!(texts[1].sha256, sha256_bytes(text.as_bytes())); 3337 dependency.license_file = Some("LICENSE".to_owned()); 3338 assert_eq!(dependency_license_texts(&dependency).unwrap().len(), 1); 3339 for path in ["../outside", "/absolute", "./LICENSE", "missing"] { 3340 dependency.license_file = Some(path.to_owned()); 3341 assert!(dependency_license_texts(&dependency).is_err(), "{path}"); 3342 } 3343 for path in [ 3344 "relative/Cargo.toml".to_owned(), 3345 root.join("not-a-manifest").to_str().unwrap().to_owned(), 3346 ] { 3347 dependency.manifest_path = path; 3348 assert!(dependency_license_texts(&dependency).is_err()); 3349 } 3350 } 3351 3352 #[test] 3353 fn dependency_license_content_and_inventory_are_bounded() { 3354 let temporary = TempDir::new().unwrap(); 3355 let root = temporary.path().canonicalize().unwrap(); 3356 let mut dependency = package( 3357 "fixture", 3358 "fixture", 3359 Some("registry+https://github.com/rust-lang/crates.io-index"), 3360 Some(&"a".repeat(64)), 3361 Some("MIT"), 3362 false, 3363 ); 3364 dependency.manifest_path = root.join("Cargo.toml").to_str().unwrap().to_owned(); 3365 dependency.license_file = Some("LICENSE".to_owned()); 3366 for bytes in [ 3367 b" \n\t".as_slice(), 3368 &[255, 254], 3369 b"-----BEGIN PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END PRIVATE KEY-----", 3370 ] { 3371 fs::write(root.join("LICENSE"), bytes).unwrap(); 3372 assert!(dependency_license_texts(&dependency).is_err()); 3373 } 3374 let file = fs::File::create(root.join("LICENSE")).unwrap(); 3375 file.set_len(MAX_TEXT_INPUT_BYTES + 1).unwrap(); 3376 assert!(dependency_license_texts(&dependency).is_err()); 3377 drop(file); 3378 fs::write(root.join("LICENSE"), b"Valid terms\n").unwrap(); 3379 dependency.license_file = None; 3380 for index in 0..16 { 3381 fs::write(root.join(format!("LICENSE-{index}")), b"Terms\n").unwrap(); 3382 } 3383 assert!(dependency_license_texts(&dependency).is_err()); 3384 for index in 0..256 { 3385 fs::write(root.join(format!("ordinary-{index}")), b"").unwrap(); 3386 } 3387 assert!(dependency_license_texts(&dependency).is_err()); 3388 } 3389 3390 #[cfg(unix)] 3391 #[test] 3392 fn dependency_license_paths_cannot_escape_the_package_root() { 3393 use std::os::unix::fs::symlink; 3394 let temporary = TempDir::new().unwrap(); 3395 let root = temporary.path().canonicalize().unwrap(); 3396 let package_root = root.join("package"); 3397 fs::create_dir(&package_root).unwrap(); 3398 let mut dependency = package( 3399 "fixture", 3400 "fixture", 3401 Some("registry+https://github.com/rust-lang/crates.io-index"), 3402 Some(&"a".repeat(64)), 3403 Some("MIT"), 3404 false, 3405 ); 3406 dependency.manifest_path = package_root.join("Cargo.toml").to_str().unwrap().to_owned(); 3407 fs::write(root.join("outside"), b"Outside terms\n").unwrap(); 3408 symlink(root.join("outside"), package_root.join("LICENSE")).unwrap(); 3409 assert!(dependency_license_texts(&dependency).is_err()); 3410 fs::create_dir(package_root.join("nested")).unwrap(); 3411 fs::write(package_root.join("nested/terms"), b"Nested terms\n").unwrap(); 3412 dependency.license_file = Some("nested/terms".to_owned()); 3413 assert!(dependency_license_texts(&dependency).is_err()); 3414 assert_eq!(fs::read(root.join("outside")).unwrap(), b"Outside terms\n"); 3415 } 3416 3417 #[test] 3418 fn contract_matches_the_checked_in_decision() { 3419 let root = Path::new(env!("CARGO_MANIFEST_DIR")) 3420 .parent() 3421 .and_then(Path::parent) 3422 .expect("workspace root"); 3423 validate_contract_inner(root).expect("release decision"); 3424 } 3425 3426 #[test] 3427 fn contract_rejects_every_independent_governed_field_drift() { 3428 let root = Path::new(env!("CARGO_MANIFEST_DIR")) 3429 .parent() 3430 .and_then(Path::parent) 3431 .expect("workspace root"); 3432 let bytes = fs::read(root.join(CONTRACT_RELATIVE)).expect("decision"); 3433 let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); 3434 for (pointer, replacement) in [ 3435 ("/schema", serde_json::json!("other")), 3436 ("/contract_version", serde_json::json!(1)), 3437 ("/decision_state", serde_json::json!("draft")), 3438 ("/owner_step", serde_json::json!(1)), 3439 ("/predecessor/schema", serde_json::json!("other")), 3440 ("/predecessor/filename", serde_json::json!("other")), 3441 ("/predecessor/transition", serde_json::json!("other")), 3442 ("/command", serde_json::json!("other")), 3443 ("/modes", serde_json::json!([])), 3444 ("/required_arguments", serde_json::json!([])), 3445 ("/service_metadata_path", serde_json::json!("other")), 3446 ("/service_metadata_fields", serde_json::json!([])), 3447 ("/service_license_path", serde_json::json!("other")), 3448 ("/source_lock_schema", serde_json::json!("other")), 3449 ("/source_lock_definition", serde_json::json!("other")), 3450 ("/artifact_contract_binding", serde_json::json!("other")), 3451 ("/artifact_admission_contract", serde_json::json!("other")), 3452 ("/supported_targets", serde_json::json!([])), 3453 ("/candidate_binding", serde_json::json!("other")), 3454 ("/lib_root_binding", serde_json::json!("other")), 3455 ("/binary_admission", serde_json::json!("other")), 3456 ("/oci_admission", serde_json::json!("other")), 3457 ("/input_inventory", serde_json::json!([])), 3458 ("/excluded_parent_owned_inputs", serde_json::json!([])), 3459 ("/service_root_inventory", serde_json::json!([])), 3460 ("/output_inventory", serde_json::json!([])), 3461 ("/canonical_json", serde_json::json!("other")), 3462 ("/checksum_format", serde_json::json!("other")), 3463 ("/source_archive_format", serde_json::json!("other")), 3464 ("/sbom_format", serde_json::json!("other")), 3465 ("/license_evidence", serde_json::json!("other")), 3466 ("/provenance_posture", serde_json::json!("other")), 3467 ("/protected_material_scan_scope", serde_json::json!("other")), 3468 ("/confidentiality_state", serde_json::json!("other")), 3469 ("/source_cleanliness", serde_json::json!("other")), 3470 ("/revision_stability", serde_json::json!("other")), 3471 ("/no_protected_material", serde_json::json!(false)), 3472 ("/maximums/text_input_bytes", serde_json::json!(1)), 3473 ("/maximums/generated_document_bytes", serde_json::json!(1)), 3474 ("/maximums/service_cargo_lock_bytes", serde_json::json!(1)), 3475 ("/maximums/service_flake_lock_bytes", serde_json::json!(1)), 3476 ("/maximums/binary_bytes", serde_json::json!(1)), 3477 ("/maximums/source_archive_bytes", serde_json::json!(1)), 3478 ( 3479 "/maximums/source_archive_member_bytes", 3480 serde_json::json!(1), 3481 ), 3482 ("/maximums/source_archive_members", serde_json::json!(1)), 3483 ("/maximums/oci_bytes", serde_json::json!(1)), 3484 ( 3485 "/maximums/artifact_scan_expanded_bytes", 3486 serde_json::json!(1), 3487 ), 3488 ("/maximums/cargo_metadata_bytes", serde_json::json!(1)), 3489 ("/maximums/packages", serde_json::json!(1)), 3490 ("/maximums/workspace_packages", serde_json::json!(1)), 3491 ("/required_negative_vectors", serde_json::json!([])), 3492 ("/negative_error_codes", serde_json::json!([])), 3493 ] { 3494 let mut drifted = canonical.clone(); 3495 *drifted.pointer_mut(pointer).expect("governed field") = replacement; 3496 let decision = serde_json::from_value::<ReleaseDecision>(drifted) 3497 .expect("structurally valid drift"); 3498 assert_eq!( 3499 validate_decision(&decision), 3500 Err(ReleaseArtifactError::InvalidContract), 3501 "accepted drift at {pointer}" 3502 ); 3503 } 3504 } 3505 3506 #[test] 3507 fn exact_inventory_and_limits_are_literal() { 3508 assert_eq!(INPUT_NAMES.len(), 6); 3509 assert_eq!(OUTPUT_NAMES.len(), 20); 3510 assert_eq!(MAX_TEXT_INPUT_BYTES, 1_048_576); 3511 assert_eq!(MAX_GENERATED_DOCUMENT_BYTES, 16_777_216); 3512 assert_eq!(MAX_SERVICE_CARGO_LOCK_BYTES, 16_777_216); 3513 assert_eq!(MAX_SERVICE_FLAKE_LOCK_BYTES, 4_194_304); 3514 assert_eq!(MAX_BINARY_BYTES, 536_870_912); 3515 assert_eq!(MAX_SOURCE_ARCHIVE_BYTES, 1_073_741_824); 3516 assert_eq!(MAX_SOURCE_ARCHIVE_MEMBER_BYTES, 67_108_864); 3517 assert_eq!(MAX_SOURCE_ARCHIVE_MEMBERS, 65_536); 3518 assert_eq!(MAX_OCI_BYTES, 2_147_483_648); 3519 assert_eq!(MAX_METADATA_BYTES, 33_554_432); 3520 assert_eq!(MAX_PACKAGES, 8_192); 3521 assert_eq!(MAX_WORKSPACE_PACKAGES, 64); 3522 } 3523 3524 #[test] 3525 fn supply_chain_documents_are_deterministic_and_complete() { 3526 let root_id = "path+file:///fixture#fixture-service@0.1.0-alpha"; 3527 let dependency_id = "registry+https://example.invalid#index@0.1.0-alpha"; 3528 let cargo = CargoMetadata { 3529 packages: vec![ 3530 package( 3531 dependency_id, 3532 "dependency", 3533 Some("registry+https://github.com/rust-lang/crates.io-index"), 3534 Some(&"a".repeat(64)), 3535 Some("Apache-2.0"), 3536 false, 3537 ), 3538 package(root_id, "fixture-service", None, None, Some("MIT"), true), 3539 ], 3540 workspace_members: vec![root_id.to_owned()], 3541 resolve: Some(CargoResolve { 3542 nodes: vec![ 3543 CargoNode { 3544 id: dependency_id.to_owned(), 3545 dependencies: Vec::new(), 3546 }, 3547 CargoNode { 3548 id: root_id.to_owned(), 3549 dependencies: vec![dependency_id.to_owned()], 3550 }, 3551 ], 3552 }), 3553 }; 3554 let (sbom, notices, licenses) = 3555 build_supply_chain_documents(&sample_metadata(), cargo).expect("documents"); 3556 let bytes = serde_json::to_vec(&sbom).expect("SBOM JSON"); 3557 assert_eq!(serde_json::to_vec(&sbom).expect("SBOM JSON"), bytes); 3558 assert_eq!(sbom.bom_format, "CycloneDX"); 3559 assert_eq!(sbom.spec_version, "1.6"); 3560 assert_eq!(sbom.metadata.component.name, "fixture-service"); 3561 assert_eq!(sbom.components.len(), 1); 3562 assert_eq!(sbom.dependencies.len(), 2); 3563 assert!(notices.contains("Package: dependency 0.1.0-alpha")); 3564 assert!(notices.contains("License: Apache-2.0")); 3565 assert!(notices.contains("registry+https://github.com/rust-lang/crates.io-index")); 3566 assert!(notices.contains("License-Text: LICENSE sha256:")); 3567 assert!(licenses.contains("fixture dependency license text")); 3568 } 3569 3570 #[test] 3571 fn license_documents_require_exact_bounded_nonempty_text_and_safe_filenames() { 3572 build_supply_chain_documents(&sample_metadata(), sample_cargo_metadata()).unwrap(); 3573 for case in ["missing", "filename", "empty", "oversized"] { 3574 let mut changed = sample_cargo_metadata(); 3575 let package = &mut changed.packages[1]; 3576 match case { 3577 "missing" => package.license_texts.clear(), 3578 "filename" => package.license_texts[0].filename = "../LICENSE".into(), 3579 "empty" => package.license_texts[0].text = " \n".into(), 3580 "oversized" => { 3581 package.license_texts[0].text = 3582 "x".repeat(MAX_GENERATED_DOCUMENT_BYTES as usize + 1) 3583 } 3584 _ => unreachable!(), 3585 } 3586 if let Some(text) = package.license_texts.first_mut() { 3587 text.sha256 = sha256_bytes(text.text.as_bytes()); 3588 } 3589 assert!( 3590 matches!( 3591 build_supply_chain_documents(&sample_metadata(), changed), 3592 Err(ReleaseArtifactError::InvalidPackageInventory) 3593 ), 3594 "{case}" 3595 ); 3596 } 3597 let mut without_newline = sample_cargo_metadata(); 3598 let text = &mut without_newline.packages[1].license_texts[0]; 3599 text.text = "Exact license terms".into(); 3600 text.sha256 = sha256_bytes(text.text.as_bytes()); 3601 let (_, _, document) = 3602 build_supply_chain_documents(&sample_metadata(), without_newline).unwrap(); 3603 assert!(document.ends_with("Exact license terms\n")); 3604 } 3605 3606 #[test] 3607 fn private_or_incomplete_dependency_evidence_is_rejected() { 3608 let root_id = "root"; 3609 for dependency in [ 3610 package( 3611 "dep", 3612 "dep", 3613 Some("git+ssh://git@github.com/private/repo"), 3614 Some(&"a".repeat(64)), 3615 Some("MIT"), 3616 false, 3617 ), 3618 package( 3619 "dep", 3620 "dep", 3621 Some("registry+https://github.com/rust-lang/crates.io-index"), 3622 Some(&"a".repeat(64)), 3623 None, 3624 false, 3625 ), 3626 ] { 3627 let cargo = CargoMetadata { 3628 packages: vec![ 3629 package(root_id, "fixture-service", None, None, Some("MIT"), true), 3630 dependency, 3631 ], 3632 workspace_members: vec![root_id.to_owned()], 3633 resolve: Some(CargoResolve { 3634 nodes: vec![ 3635 CargoNode { 3636 id: root_id.to_owned(), 3637 dependencies: vec!["dep".to_owned()], 3638 }, 3639 CargoNode { 3640 id: "dep".to_owned(), 3641 dependencies: Vec::new(), 3642 }, 3643 ], 3644 }), 3645 }; 3646 assert!(matches!( 3647 build_supply_chain_documents(&sample_metadata(), cargo), 3648 Err(ReleaseArtifactError::InvalidPackageInventory) 3649 )); 3650 } 3651 } 3652 3653 #[test] 3654 fn package_metadata_rejects_each_independent_field_drift() { 3655 let invalid = |package: CargoPackage, workspace_member| { 3656 assert_eq!( 3657 validate_metadata_package(&package, workspace_member), 3658 Err(ReleaseArtifactError::InvalidPackageInventory) 3659 ); 3660 }; 3661 for field in ["name", "version", "id"] { 3662 for value in [ 3663 String::new(), 3664 "x".repeat(MAX_TEXT_FIELD_BYTES + 1), 3665 "x\ny".into(), 3666 ] { 3667 let mut candidate = 3668 package("root", "fixture-service", None, None, Some("MIT"), true); 3669 match field { 3670 "name" => candidate.name = value, 3671 "version" => candidate.version = value, 3672 "id" => candidate.id = value, 3673 _ => unreachable!(), 3674 } 3675 invalid(candidate, true); 3676 } 3677 } 3678 3679 for license in [ 3680 Some(""), 3681 Some("bad\nlicense"), 3682 Some("-----BEGIN PRIVATE KEY-----"), 3683 ] { 3684 invalid( 3685 package("root", "fixture-service", None, None, license, true), 3686 true, 3687 ); 3688 } 3689 for source in [ 3690 Some(""), 3691 Some("path+file:///private"), 3692 Some("git+ssh://git@github.com/private/repo"), 3693 ] { 3694 invalid( 3695 package( 3696 "dep", 3697 "dep", 3698 source, 3699 Some(&"a".repeat(64)), 3700 Some("MIT"), 3701 false, 3702 ), 3703 false, 3704 ); 3705 } 3706 invalid( 3707 package( 3708 "dep", 3709 "dep", 3710 None, 3711 Some(&"a".repeat(64)), 3712 Some("MIT"), 3713 false, 3714 ), 3715 false, 3716 ); 3717 invalid( 3718 package( 3719 "dep", 3720 "dep", 3721 Some("registry+https://github.com/rust-lang/crates.io-index"), 3722 Some(&"a".repeat(64)), 3723 None, 3724 false, 3725 ), 3726 false, 3727 ); 3728 for checksum in ["a".repeat(63), "g".repeat(64)] { 3729 invalid( 3730 package( 3731 "root", 3732 "fixture-service", 3733 None, 3734 Some(&checksum), 3735 Some("MIT"), 3736 true, 3737 ), 3738 true, 3739 ); 3740 } 3741 } 3742 3743 #[test] 3744 fn supply_chain_graph_rejects_each_independent_structural_drift() { 3745 let invalid = |cargo| { 3746 assert!(matches!( 3747 build_supply_chain_documents(&sample_metadata(), cargo), 3748 Err(ReleaseArtifactError::InvalidPackageInventory) 3749 )); 3750 }; 3751 3752 let mut cargo = sample_cargo_metadata(); 3753 cargo.packages.clear(); 3754 invalid(cargo); 3755 let mut cargo = sample_cargo_metadata(); 3756 cargo.workspace_members.clear(); 3757 invalid(cargo); 3758 let mut cargo = sample_cargo_metadata(); 3759 cargo.packages[0].version = "0.2.0".into(); 3760 invalid(cargo); 3761 let mut cargo = sample_cargo_metadata(); 3762 cargo.packages[0].targets.clear(); 3763 invalid(cargo); 3764 let mut cargo = sample_cargo_metadata(); 3765 cargo.packages[0].name = "other".into(); 3766 invalid(cargo); 3767 let mut cargo = sample_cargo_metadata(); 3768 cargo.packages.push(package( 3769 "second-root", 3770 "fixture-service", 3771 None, 3772 None, 3773 Some("MIT"), 3774 true, 3775 )); 3776 cargo.workspace_members.push("second-root".into()); 3777 invalid(cargo); 3778 let mut cargo = sample_cargo_metadata(); 3779 cargo.packages[1].id = cargo.packages[0].id.clone(); 3780 invalid(cargo); 3781 let mut cargo = sample_cargo_metadata(); 3782 cargo.workspace_members.push("missing".into()); 3783 invalid(cargo); 3784 let mut cargo = sample_cargo_metadata(); 3785 cargo.resolve = None; 3786 invalid(cargo); 3787 let mut cargo = sample_cargo_metadata(); 3788 cargo.resolve.as_mut().expect("resolve").nodes.clear(); 3789 invalid(cargo); 3790 let mut cargo = sample_cargo_metadata(); 3791 let duplicate = cargo.resolve.as_ref().expect("resolve").nodes[0].id.clone(); 3792 cargo.resolve.as_mut().expect("resolve").nodes[1].id = duplicate; 3793 invalid(cargo); 3794 let mut cargo = sample_cargo_metadata(); 3795 cargo.resolve.as_mut().expect("resolve").nodes[0].id = "missing".into(); 3796 invalid(cargo); 3797 let mut cargo = sample_cargo_metadata(); 3798 cargo.resolve.as_mut().expect("resolve").nodes[1] 3799 .dependencies 3800 .push("missing".into()); 3801 invalid(cargo); 3802 3803 let root_id = "path+file:///fixture#fixture-service@0.1.0-alpha"; 3804 let root_only = CargoMetadata { 3805 packages: vec![package( 3806 root_id, 3807 "fixture-service", 3808 None, 3809 None, 3810 Some("MIT"), 3811 true, 3812 )], 3813 workspace_members: vec![root_id.into()], 3814 resolve: Some(CargoResolve { 3815 nodes: vec![CargoNode { 3816 id: root_id.into(), 3817 dependencies: Vec::new(), 3818 }], 3819 }), 3820 }; 3821 let (_, notices, licenses) = 3822 build_supply_chain_documents(&sample_metadata(), root_only).expect("root-only graph"); 3823 assert!(notices.contains("No third-party Cargo packages are present.")); 3824 assert!(licenses.contains("No third-party Cargo packages are present.")); 3825 } 3826 3827 #[test] 3828 fn file_admission_predicates_reject_each_independent_drift() { 3829 let root = TempDir::new().expect("file fixture"); 3830 let regular = root.path().join("regular"); 3831 write_file(®ular, b"bytes"); 3832 let empty = root.path().join("empty"); 3833 write_file(&empty, b""); 3834 let oversized = root.path().join("oversized"); 3835 fs::File::create(&oversized) 3836 .and_then(|file| file.set_len(6)) 3837 .expect("sparse file"); 3838 assert!(matches!( 3839 hash_regular(root.path(), 5), 3840 Err(ReleaseArtifactError::InvalidInputArtifact) 3841 )); 3842 assert!(matches!( 3843 hash_regular(&oversized, 5), 3844 Err(ReleaseArtifactError::InvalidInputArtifact) 3845 )); 3846 assert!(matches!( 3847 validate_regular_input(&empty, 5), 3848 Err(ReleaseArtifactError::InvalidInputArtifact) 3849 )); 3850 assert!(matches!( 3851 validate_regular_input(root.path(), 5), 3852 Err(ReleaseArtifactError::InvalidInputArtifact) 3853 )); 3854 assert!(matches!( 3855 validate_regular_input(&oversized, 5), 3856 Err(ReleaseArtifactError::InvalidInputArtifact) 3857 )); 3858 assert_eq!( 3859 validate_absolute_directory(®ular, ReleaseArtifactError::InvalidInputRoot), 3860 Err(ReleaseArtifactError::InvalidInputRoot) 3861 ); 3862 assert_eq!( 3863 validate_absolute_directory( 3864 Path::new("relative"), 3865 ReleaseArtifactError::InvalidInputRoot 3866 ), 3867 Err(ReleaseArtifactError::InvalidInputRoot) 3868 ); 3869 assert_eq!( 3870 output_maximum("unknown"), 3871 Err(ReleaseArtifactError::GenerationFailure) 3872 ); 3873 3874 #[cfg(unix)] 3875 { 3876 let symlink = root.path().join("symlink"); 3877 std::os::unix::fs::symlink(®ular, &symlink).expect("symlink"); 3878 assert!(matches!( 3879 hash_regular(&symlink, 5), 3880 Err(ReleaseArtifactError::InvalidInputArtifact) 3881 )); 3882 assert!(matches!( 3883 validate_regular_input(&symlink, 5), 3884 Err(ReleaseArtifactError::InvalidInputArtifact) 3885 )); 3886 assert_eq!( 3887 validate_absolute_directory(&symlink, ReleaseArtifactError::InvalidInputRoot), 3888 Err(ReleaseArtifactError::InvalidInputRoot) 3889 ); 3890 } 3891 } 3892 3893 #[test] 3894 fn release_metadata_and_text_admission_reject_each_field_drift() { 3895 let root = TempDir::new().expect("metadata fixture"); 3896 let canonical = r#"[workspace.metadata.radroots.service_release] 3897 service = "fixture_service" 3898 service_package = "fixture-service" 3899 binary_name = "fixture-service" 3900 version = "0.1.0-alpha" 3901 "#; 3902 for (from, to) in [ 3903 ("fixture_service", "Fixture"), 3904 ( 3905 "service_package = \"fixture-service\"", 3906 "service_package = \"fixture--service\"", 3907 ), 3908 ( 3909 "binary_name = \"fixture-service\"", 3910 "binary_name = \"fixture--service\"", 3911 ), 3912 ("version = \"0.1.0-alpha\"", "version = \"invalid\""), 3913 ] { 3914 write_file( 3915 &root.path().join("Cargo.toml"), 3916 canonical.replacen(from, to, 1).as_bytes(), 3917 ); 3918 assert!(matches!( 3919 read_release_metadata(root.path()), 3920 Err(ReleaseArtifactError::InvalidServiceMetadata) 3921 )); 3922 } 3923 write_file( 3924 &root.path().join("Cargo.toml"), 3925 canonical 3926 .replacen("0.1.0-alpha", &"a".repeat(129), 1) 3927 .as_bytes(), 3928 ); 3929 assert!(matches!( 3930 read_release_metadata(root.path()), 3931 Err(ReleaseArtifactError::InvalidServiceMetadata) 3932 )); 3933 3934 for (name, bytes) in [ 3935 ("plain.txt", b"contains\0nul".as_slice()), 3936 ("config.schema.json", b"not json".as_slice()), 3937 ("config.example.toml", b"not = [toml".as_slice()), 3938 ] { 3939 let path = root.path().join(name); 3940 write_file(&path, bytes); 3941 assert_eq!( 3942 validate_text_artifact(&path, name), 3943 Err(ReleaseArtifactError::InvalidInputArtifact) 3944 ); 3945 } 3946 assert_eq!( 3947 write_generated(&root.path().join("empty-generated"), b""), 3948 Err(ReleaseArtifactError::GenerationFailure) 3949 ); 3950 } 3951 3952 #[test] 3953 fn output_scope_remote_and_inventory_reject_each_drift() { 3954 let root = TempDir::new().expect("output fixture"); 3955 let service = root.path().join("service"); 3956 let input = root.path().join("input"); 3957 let output_parent = root.path().join("output"); 3958 fs::create_dir(&service).expect("service"); 3959 fs::create_dir(&input).expect("input"); 3960 fs::create_dir(&output_parent).expect("output"); 3961 assert_eq!( 3962 validate_output_parent(Path::new("relative"), &service, &input), 3963 Err(ReleaseArtifactError::InvalidOutputRoot) 3964 ); 3965 assert_eq!( 3966 validate_output_parent(root.path(), &service, &input), 3967 Err(ReleaseArtifactError::InvalidOutputRoot) 3968 ); 3969 let file_output = output_parent.join("file"); 3970 write_file(&file_output, b"file"); 3971 assert_eq!( 3972 validate_output_parent(&file_output, &service, &input), 3973 Err(ReleaseArtifactError::InvalidOutputRoot) 3974 ); 3975 3976 let inventory = root.path().join("inventory"); 3977 fs::create_dir(&inventory).expect("inventory"); 3978 fs::create_dir(inventory.join("directory-entry")).expect("directory entry"); 3979 assert_eq!( 3980 directory_inventory(&inventory, ReleaseArtifactError::InvalidInputRoot), 3981 Err(ReleaseArtifactError::InvalidInputRoot) 3982 ); 3983 fs::remove_dir(inventory.join("directory-entry")).expect("remove entry"); 3984 for index in 0..=OUTPUT_NAMES.len() { 3985 write_file(&inventory.join(format!("file-{index}")), b"x"); 3986 } 3987 assert_eq!( 3988 directory_inventory(&inventory, ReleaseArtifactError::InvalidInputRoot), 3989 Err(ReleaseArtifactError::InvalidInputRoot) 3990 ); 3991 3992 write_file(&service.join("fixture"), b"fixture"); 3993 initialize_git(&service, "https://example.invalid/service"); 3994 assert_eq!( 3995 git_remote(&service), 3996 Err(ReleaseArtifactError::InvalidServiceRoot) 3997 ); 3998 git( 3999 &service, 4000 &[ 4001 "remote", 4002 "set-url", 4003 "origin", 4004 "ssh://git@github.com/user/repo\nbad", 4005 ], 4006 ); 4007 assert_eq!( 4008 git_remote(&service), 4009 Err(ReleaseArtifactError::InvalidServiceRoot) 4010 ); 4011 git( 4012 &service, 4013 &[ 4014 "remote", 4015 "set-url", 4016 "origin", 4017 &format!("https://github.com/{}", "a".repeat(MAX_TEXT_FIELD_BYTES)), 4018 ], 4019 ); 4020 assert_eq!( 4021 git_remote(&service), 4022 Err(ReleaseArtifactError::InvalidServiceRoot) 4023 ); 4024 } 4025 4026 #[cfg(unix)] 4027 #[test] 4028 fn snapshot_backed_inventory_rejects_file_and_root_mode_races() { 4029 use std::os::unix::fs::PermissionsExt as _; 4030 4031 let directory = TempDir::new().expect("inventory fixture"); 4032 let root = directory 4033 .path() 4034 .canonicalize() 4035 .expect("canonical inventory fixture"); 4036 set_directory_mode(&root).expect("directory mode"); 4037 let file = root.join("LICENSE-MIT"); 4038 write_file(&file, b"license\n"); 4039 set_file_mode(&file).expect("file mode"); 4040 4041 assert_eq!( 4042 inventory_records_impl(&root, || { 4043 fs::set_permissions(&file, fs::Permissions::from_mode(0o600)) 4044 .expect("change file mode"); 4045 }), 4046 Err(ReleaseArtifactError::GenerationFailure) 4047 ); 4048 set_file_mode(&file).expect("restore file mode"); 4049 assert_eq!( 4050 inventory_records_impl(&root, || { 4051 fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) 4052 .expect("change root mode"); 4053 }), 4054 Err(ReleaseArtifactError::GenerationFailure) 4055 ); 4056 set_directory_mode(&root).expect("restore directory mode"); 4057 fs::set_permissions(&file, fs::Permissions::from_mode(0o4644)) 4058 .expect("add file special mode bit"); 4059 assert_eq!( 4060 inventory_records(&root), 4061 Err(ReleaseArtifactError::GenerationFailure) 4062 ); 4063 set_file_mode(&file).expect("restore file mode"); 4064 fs::set_permissions(&root, fs::Permissions::from_mode(0o1755)) 4065 .expect("add directory special mode bit"); 4066 assert_eq!( 4067 inventory_records(&root), 4068 Err(ReleaseArtifactError::GenerationFailure) 4069 ); 4070 } 4071 4072 #[test] 4073 fn remaining_release_boundaries_fail_closed() { 4074 let fixture = ReleaseFixture::new(); 4075 assert_eq!( 4076 fixture.check(&fixture.output_a), 4077 Err(ReleaseArtifactError::StaleOutput) 4078 ); 4079 4080 let source_lock = ServiceSourceLockV3::from_canonical_bytes( 4081 &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"), 4082 ) 4083 .expect("source lock"); 4084 fs::write(fixture.service.join("flake.lock"), b"different").expect("flake drift"); 4085 assert_eq!( 4086 validate_source_lock_files(&fixture.service, &source_lock), 4087 Err(ReleaseArtifactError::InvalidSourceLock) 4088 ); 4089 4090 let mut packages = sample_cargo_metadata(); 4091 packages.packages = (0..=MAX_PACKAGES) 4092 .map(|index| { 4093 package( 4094 &format!("id-{index}"), 4095 "dep", 4096 None, 4097 None, 4098 Some("MIT"), 4099 false, 4100 ) 4101 }) 4102 .collect(); 4103 assert!(matches!( 4104 build_supply_chain_documents(&sample_metadata(), packages), 4105 Err(ReleaseArtifactError::InvalidPackageInventory) 4106 )); 4107 let mut workspace = sample_cargo_metadata(); 4108 workspace.workspace_members = (0..=MAX_WORKSPACE_PACKAGES) 4109 .map(|index| format!("member-{index}")) 4110 .collect(); 4111 assert!(matches!( 4112 build_supply_chain_documents(&sample_metadata(), workspace), 4113 Err(ReleaseArtifactError::InvalidPackageInventory) 4114 )); 4115 for (target_name, kind) in [("other", "bin"), ("fixture-service", "lib")] { 4116 let mut cargo = sample_cargo_metadata(); 4117 cargo.packages[0].targets = vec![CargoTarget { 4118 name: target_name.into(), 4119 kind: vec![kind.into()], 4120 }]; 4121 assert!(matches!( 4122 build_supply_chain_documents(&sample_metadata(), cargo), 4123 Err(ReleaseArtifactError::InvalidPackageInventory) 4124 )); 4125 } 4126 let mut outside_workspace = sample_cargo_metadata(); 4127 outside_workspace.workspace_members = vec!["other".into()]; 4128 assert!(matches!( 4129 build_supply_chain_documents(&sample_metadata(), outside_workspace), 4130 Err(ReleaseArtifactError::InvalidPackageInventory) 4131 )); 4132 4133 let scope = TempDir::new().expect("scope fixture"); 4134 let service = scope.path().join("service"); 4135 let input_parent = TempDir::new().expect("input scope"); 4136 let input = input_parent.path().join("input"); 4137 fs::create_dir(&service).expect("service"); 4138 fs::create_dir(&input).expect("input"); 4139 assert_eq!( 4140 validate_output_parent(&scope.path().join("bad\\name"), &service, &input), 4141 Err(ReleaseArtifactError::InvalidOutputRoot) 4142 ); 4143 assert_eq!( 4144 validate_output_parent(input_parent.path(), &service, &input), 4145 Err(ReleaseArtifactError::InvalidOutputRoot) 4146 ); 4147 #[cfg(unix)] 4148 { 4149 let foreign = scope.path().join("foreign"); 4150 fs::create_dir(&foreign).expect("foreign"); 4151 let output = scope.path().join("output-link"); 4152 std::os::unix::fs::symlink(&foreign, &output).expect("output symlink"); 4153 assert_eq!( 4154 validate_output_parent(&output, &service, &input), 4155 Err(ReleaseArtifactError::InvalidOutputRoot) 4156 ); 4157 } 4158 4159 write_file(&service.join("tracked"), b"tracked"); 4160 initialize_git(&service, "https://github.com/radrootslabs/service"); 4161 let child = service.join("child"); 4162 fs::create_dir(&child).expect("child"); 4163 assert_eq!( 4164 validate_git_root(&child), 4165 Err(ReleaseArtifactError::InvalidServiceRoot) 4166 ); 4167 write_file(&service.join("untracked"), b"dirty"); 4168 assert_eq!( 4169 validate_clean_git(&service), 4170 Err(ReleaseArtifactError::DirtyServiceSource) 4171 ); 4172 4173 assert_eq!( 4174 write_generated( 4175 &scope.path().join("oversized-generated"), 4176 &vec![b'x'; MAX_GENERATED_DOCUMENT_BYTES as usize + 1] 4177 ), 4178 Err(ReleaseArtifactError::GenerationFailure) 4179 ); 4180 } 4181 4182 #[test] 4183 fn low_level_release_admission_and_comparison_branches_are_qualified() { 4184 let root = TempDir::new().expect("low-level release fixture"); 4185 let regular = root.path().join("regular"); 4186 write_file(®ular, b"same"); 4187 assert_eq!( 4188 read_bounded_regular(®ular, 4, ReleaseArtifactError::InvalidInputArtifact) 4189 .expect("bounded regular file"), 4190 b"same" 4191 ); 4192 let directory = root.path().join("directory"); 4193 fs::create_dir(&directory).expect("directory fixture"); 4194 assert_eq!( 4195 read_bounded_regular(&directory, 4, ReleaseArtifactError::InvalidInputArtifact), 4196 Err(ReleaseArtifactError::InvalidInputArtifact) 4197 ); 4198 let oversized = root.path().join("oversized"); 4199 write_file(&oversized, b"12345"); 4200 assert_eq!( 4201 read_bounded_regular(&oversized, 4, ReleaseArtifactError::InvalidInputArtifact), 4202 Err(ReleaseArtifactError::InvalidInputArtifact) 4203 ); 4204 4205 #[cfg(unix)] 4206 { 4207 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; 4208 4209 let symlink = root.path().join("regular-link"); 4210 std::os::unix::fs::symlink(®ular, &symlink).expect("regular symlink"); 4211 assert_eq!( 4212 read_bounded_regular(&symlink, 4, ReleaseArtifactError::InvalidInputArtifact), 4213 Err(ReleaseArtifactError::InvalidInputArtifact) 4214 ); 4215 4216 let expected = fs::symlink_metadata(®ular).expect("regular metadata"); 4217 assert_eq!( 4218 validate_unchanged_input(&symlink, &expected), 4219 Err(ReleaseArtifactError::InvalidInputArtifact) 4220 ); 4221 assert_eq!( 4222 validate_unchanged_input(&directory, &expected), 4223 Err(ReleaseArtifactError::InvalidInputArtifact) 4224 ); 4225 4226 let other = root.path().join("other"); 4227 write_file(&other, b"same"); 4228 assert_eq!( 4229 validate_unchanged_input(&other, &expected), 4230 Err(ReleaseArtifactError::InvalidInputArtifact) 4231 ); 4232 4233 let device_metadata = fs::symlink_metadata("/dev/null").expect("device metadata"); 4234 assert_ne!(device_metadata.dev(), expected.dev()); 4235 assert_eq!( 4236 validate_unchanged_input(®ular, &device_metadata), 4237 Err(ReleaseArtifactError::InvalidInputArtifact) 4238 ); 4239 4240 let same_inode = fs::symlink_metadata(®ular).expect("same-inode metadata"); 4241 write_file(®ular, b"changed length"); 4242 assert_eq!( 4243 validate_unchanged_input(®ular, &same_inode), 4244 Err(ReleaseArtifactError::InvalidInputArtifact) 4245 ); 4246 4247 let inventory = root.path().join("symlink-inventory"); 4248 fs::create_dir(&inventory).expect("symlink inventory"); 4249 std::os::unix::fs::symlink(&other, inventory.join("entry")).expect("inventory symlink"); 4250 assert_eq!( 4251 directory_inventory(&inventory, ReleaseArtifactError::InvalidInputRoot), 4252 Err(ReleaseArtifactError::InvalidInputRoot) 4253 ); 4254 4255 let mode_file = root.path().join("mode-file"); 4256 write_file(&mode_file, b"mode"); 4257 fs::set_permissions(&mode_file, fs::Permissions::from_mode(0o600)) 4258 .expect("set invalid file mode"); 4259 assert_eq!( 4260 validate_file_mode(&mode_file), 4261 Err(ReleaseArtifactError::StaleOutput) 4262 ); 4263 fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)) 4264 .expect("set invalid directory mode"); 4265 assert_eq!( 4266 validate_directory_mode(&directory), 4267 Err(ReleaseArtifactError::StaleOutput) 4268 ); 4269 } 4270 4271 let incomplete = root.path().join("incomplete-output"); 4272 fs::create_dir(&incomplete).expect("incomplete output"); 4273 write_file(&incomplete.join("LICENSE-MIT"), b"x"); 4274 assert_eq!( 4275 validate_exact_output_inventory(&incomplete), 4276 Err(ReleaseArtifactError::GenerationFailure) 4277 ); 4278 4279 let expected = root.path().join("expected-output"); 4280 let actual = root.path().join("actual-output"); 4281 fs::create_dir(&expected).expect("expected output"); 4282 fs::create_dir(&actual).expect("actual output"); 4283 set_directory_mode(&expected).expect("expected directory mode"); 4284 set_directory_mode(&actual).expect("actual directory mode"); 4285 for name in OUTPUT_NAMES { 4286 write_file(&expected.join(name), b"a"); 4287 write_file(&actual.join(name), b"a"); 4288 set_file_mode(&expected.join(name)).expect("expected file mode"); 4289 set_file_mode(&actual.join(name)).expect("actual file mode"); 4290 } 4291 compare_output(&expected, &actual).expect("matching output"); 4292 let records = inventory_records(&actual).expect("actual records"); 4293 validate_output_records(&actual, &records).expect("matching records"); 4294 write_file(&actual.join("LICENSE-MIT"), b"b"); 4295 set_file_mode(&actual.join("LICENSE-MIT")).expect("restored file mode"); 4296 assert_eq!( 4297 compare_output(&expected, &actual), 4298 Err(ReleaseArtifactError::StaleOutput) 4299 ); 4300 4301 let mut oversized_stdout = Command::new("sh"); 4302 oversized_stdout.args(["-c", "printf 12345"]); 4303 assert_eq!(command_stdout(&mut oversized_stdout, 4), Err(())); 4304 let mut failed_stdout = Command::new("sh"); 4305 failed_stdout.args(["-c", "exit 7"]); 4306 assert_eq!(command_stdout(&mut failed_stdout, 4), Err(())); 4307 } 4308 4309 #[test] 4310 fn release_service_and_workspace_binding_fail_closed() { 4311 let fixture = ReleaseFixture::new(); 4312 let mismatched = fs::read_to_string(fixture.service.join(LOCK_FILENAME)) 4313 .expect("source lock") 4314 .replace("service = \"myc\"", "service = \"rhi\""); 4315 write_file(&fixture.service.join(LOCK_FILENAME), mismatched.as_bytes()); 4316 git(&fixture.service, &["add", LOCK_FILENAME]); 4317 git( 4318 &fixture.service, 4319 &["commit", "--quiet", "-m", "mismatched service lock"], 4320 ); 4321 assert_eq!( 4322 fixture.check(&fixture.output_a), 4323 Err(ReleaseArtifactError::InvalidSourceLock) 4324 ); 4325 4326 let mut cargo = sample_cargo_metadata(); 4327 let dependency_id = cargo.packages[1].id.clone(); 4328 cargo.packages[0].source = 4329 Some("registry+https://github.com/rust-lang/crates.io-index".into()); 4330 cargo.packages[0].checksum = Some("a".repeat(64)); 4331 cargo.workspace_members = vec![dependency_id]; 4332 assert!(matches!( 4333 build_supply_chain_documents(&sample_metadata(), cargo), 4334 Err(ReleaseArtifactError::InvalidPackageInventory) 4335 )); 4336 } 4337 4338 #[test] 4339 fn identifier_predicates_reject_each_independent_boundary() { 4340 for value in ["", "1service", "service_", "service__name", "service-name"] { 4341 assert!(!valid_snake_identifier(value), "{value}"); 4342 } 4343 assert!(!valid_snake_identifier(&"a".repeat(129))); 4344 for value in ["", "1service", "service-", "service--name", "service_name"] { 4345 assert!(!valid_kebab_identifier(value), "{value}"); 4346 } 4347 assert!(!valid_kebab_identifier(&"a".repeat(129))); 4348 assert!(!valid_metadata_text("")); 4349 assert!(!valid_metadata_text(&"a".repeat(MAX_TEXT_FIELD_BYTES + 1))); 4350 assert!(!valid_metadata_text("bad\rvalue")); 4351 assert!(!valid_metadata_text("-----BEGIN PRIVATE KEY-----")); 4352 assert!(!valid_public_source("registry+http://example.invalid")); 4353 assert!(!valid_public_source( 4354 "registry+https://user@github.com/private" 4355 )); 4356 assert!(!valid_lower_hex("a", 2)); 4357 assert!(!valid_lower_hex("ag", 2)); 4358 } 4359 4360 #[test] 4361 fn binary_archive_is_reproducible_and_metadata_is_fixed() { 4362 let root = TempDir::new().expect("archive fixture"); 4363 let root = root 4364 .path() 4365 .canonicalize() 4366 .expect("canonical archive fixture"); 4367 let source = root.join("service"); 4368 let first = root.join("first.tar.gz"); 4369 let second = root.join("second.tar.gz"); 4370 write_file(&source, b"exact executable bytes"); 4371 create_binary_archive(&source, &first, "fixture-service", 1_700_000_000) 4372 .expect("first archive"); 4373 create_binary_archive(&source, &second, "fixture-service", 1_700_000_000) 4374 .expect("second archive"); 4375 assert_eq!( 4376 fs::read(&first).expect("first"), 4377 fs::read(&second).expect("second") 4378 ); 4379 4380 let decoder = flate2::read::GzDecoder::new(fs::File::open(first).expect("archive")); 4381 let mut archive = tar::Archive::new(decoder); 4382 let mut entries = archive.entries().expect("entries"); 4383 let mut entry = entries.next().expect("one entry").expect("entry"); 4384 assert_eq!( 4385 entry.path().expect("path").as_ref(), 4386 Path::new("bin/fixture-service") 4387 ); 4388 assert_eq!(entry.header().mode().expect("mode"), 0o755); 4389 assert_eq!(entry.header().mtime().expect("mtime"), 1_700_000_000); 4390 let mut bytes = Vec::new(); 4391 entry.read_to_end(&mut bytes).expect("archive bytes"); 4392 assert_eq!(bytes, b"exact executable bytes"); 4393 assert!(entries.next().is_none()); 4394 } 4395 4396 #[test] 4397 fn full_artifact_set_is_reproducible_immutable_and_verifiable() { 4398 let fixture = ReleaseFixture::new(); 4399 fixture.write(&fixture.output_a).expect("first release"); 4400 fixture.write(&fixture.output_b).expect("second release"); 4401 fixture.check(&fixture.output_a).expect("check release"); 4402 assert_eq!( 4403 directory_inventory(&fixture.output_a, ReleaseArtifactError::StaleOutput) 4404 .expect("first inventory"), 4405 OUTPUT_NAMES.into_iter().map(str::to_owned).collect() 4406 ); 4407 for name in OUTPUT_NAMES { 4408 assert_eq!( 4409 fs::read(fixture.output_a.join(name)).expect("first output"), 4410 fs::read(fixture.output_b.join(name)).expect("second output"), 4411 "{name}" 4412 ); 4413 } 4414 4415 let sums = 4416 fs::read_to_string(fixture.output_a.join("SHA256SUMS")).expect("checksum inventory"); 4417 let mut prior = ""; 4418 let mut count = 0; 4419 for line in sums.lines() { 4420 let (digest, name) = line.split_once(" ").expect("checksum line"); 4421 assert!(prior < name); 4422 assert!(valid_lower_hex(digest, 64)); 4423 assert_eq!( 4424 digest, 4425 hash_regular( 4426 &fixture.output_a.join(name), 4427 output_maximum(name).expect("known output") 4428 ) 4429 .expect("artifact hash") 4430 .sha256 4431 ); 4432 prior = name; 4433 count += 1; 4434 } 4435 assert_eq!(count, OUTPUT_NAMES.len() - 1); 4436 4437 for name in [ 4438 "artifact-manifest.v2.json", 4439 "artifact-scan.v1.json", 4440 "oci-image.v1.json", 4441 "provenance.intoto.jsonl", 4442 "sbom.cdx.json", 4443 "source-archives.v3.json", 4444 ] { 4445 let bytes = fs::read(fixture.output_a.join(name)).expect("JSON output"); 4446 assert_eq!(bytes.last(), Some(&b'\n')); 4447 assert_eq!(bytes.iter().filter(|byte| **byte == b'\n').count(), 1); 4448 serde_json::from_slice::<serde_json::Value>(&bytes).expect("valid JSON"); 4449 } 4450 let provenance: serde_json::Value = serde_json::from_slice( 4451 &fs::read(fixture.output_a.join("provenance.intoto.jsonl")).expect("provenance"), 4452 ) 4453 .expect("provenance JSON"); 4454 assert_eq!(provenance["_type"], "https://in-toto.io/Statement/v1"); 4455 assert_eq!( 4456 provenance["predicateType"], 4457 "https://slsa.dev/provenance/v1" 4458 ); 4459 assert_eq!( 4460 provenance["predicate"]["buildDefinition"]["externalParameters"]["candidate_digest"], 4461 format!("sha256:{}", "a".repeat(64)) 4462 ); 4463 4464 write_file( 4465 &fixture.output_a.join("config.example.toml"), 4466 b"tampered = true\n", 4467 ); 4468 assert_eq!( 4469 fixture.check(&fixture.output_a), 4470 Err(ReleaseArtifactError::StaleOutput) 4471 ); 4472 assert_eq!( 4473 fixture.write(&fixture.output_a), 4474 Err(ReleaseArtifactError::StaleOutput) 4475 ); 4476 } 4477 4478 #[test] 4479 fn qualified_nix_material_is_required_and_preserved() { 4480 let fixture = ReleaseFixture::new(); 4481 fixture 4482 .write(&fixture.output_a) 4483 .expect("qualified-Nix release"); 4484 4485 let document: serde_json::Value = serde_json::from_slice( 4486 &fs::read(fixture.output_a.join("source-archives.v3.json")) 4487 .expect("source archive document"), 4488 ) 4489 .expect("source archive JSON"); 4490 assert_eq!(document["schema"], "radroots.service.source-archives.v3"); 4491 assert_eq!(document["contract_version"], 3); 4492 assert!(valid_lower_hex( 4493 document["flake_lock_sha256"] 4494 .as_str() 4495 .expect("flake digest"), 4496 64 4497 )); 4498 4499 let lock = ServiceSourceLockV3::from_canonical_bytes( 4500 &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"), 4501 ) 4502 .expect("valid source lock"); 4503 for name in ["flake.nix", "flake.lock"] { 4504 let path = fixture.service.join(name); 4505 let original = fs::read(&path).expect("qualified Nix file"); 4506 fs::remove_file(&path).expect("remove qualified Nix file"); 4507 assert_eq!( 4508 validate_source_lock_files(&fixture.service, &lock), 4509 Err(ReleaseArtifactError::InvalidSourceLock), 4510 "accepted missing qualified-Nix file {name}" 4511 ); 4512 write_file(&path, &original); 4513 } 4514 write_file( 4515 &fixture.service.join(PREDECESSOR_LOCK_FILENAME), 4516 b"unexpected", 4517 ); 4518 assert_eq!( 4519 validate_source_lock_files(&fixture.service, &lock), 4520 Err(ReleaseArtifactError::InvalidSourceLock) 4521 ); 4522 } 4523 4524 #[test] 4525 fn protected_text_and_invalid_inventory_fail_closed() { 4526 let fixture = ReleaseFixture::new(); 4527 let mut secret = b"-----BEGIN PRIVATE KEY-----\n".to_vec(); 4528 secret.extend_from_slice(&[b'A'; 48]); 4529 secret.extend_from_slice(b"\n-----END PRIVATE KEY-----\n"); 4530 write_file(&fixture.input.join("config.example.toml"), &secret); 4531 assert_eq!( 4532 fixture.write(&fixture.output_a), 4533 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4534 ); 4535 write_file( 4536 &fixture.input.join("config.example.toml"), 4537 b"enabled = true\n", 4538 ); 4539 write_file(&fixture.input.join("unexpected"), b"unexpected\n"); 4540 assert_eq!( 4541 fixture.write(&fixture.output_a), 4542 Err(ReleaseArtifactError::InvalidInputRoot) 4543 ); 4544 } 4545 4546 #[test] 4547 fn source_lock_and_source_archive_drift_fail_closed() { 4548 let fixture = ReleaseFixture::new(); 4549 write_file( 4550 &fixture.service.join("Cargo.lock"), 4551 b"version = 4\n# changed after source lock\n", 4552 ); 4553 git(&fixture.service, &["add", "Cargo.lock"]); 4554 git( 4555 &fixture.service, 4556 &["commit", "--quiet", "-m", "change lock"], 4557 ); 4558 assert_eq!( 4559 fixture.write(&fixture.output_a), 4560 Err(ReleaseArtifactError::InvalidSourceLock) 4561 ); 4562 4563 let fixture = ReleaseFixture::new(); 4564 let current = ServiceSourceLockV3::from_canonical_bytes( 4565 &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"), 4566 ) 4567 .expect("source lock"); 4568 let drifted = String::from_utf8(current.canonical_bytes().to_vec()) 4569 .expect("UTF-8 lock") 4570 .replace(current.source_archive_sha256(), &"0".repeat(64)); 4571 write_file(&fixture.service.join(LOCK_FILENAME), drifted.as_bytes()); 4572 git(&fixture.service, &["add", LOCK_FILENAME]); 4573 git( 4574 &fixture.service, 4575 &["commit", "--quiet", "-m", "drift source archive digest"], 4576 ); 4577 create_oci_fixture( 4578 &fixture.input.join("oci-image.tar.gz"), 4579 &git_output(&fixture.service, &["rev-parse", "HEAD"]), 4580 current.revision(), 4581 ); 4582 assert_eq!( 4583 fixture.write(&fixture.output_a), 4584 Err(ReleaseArtifactError::InvalidSourceBundle) 4585 ); 4586 } 4587 4588 #[test] 4589 fn output_name_is_one_bounded_component() { 4590 assert!(valid_output_component("release-v1")); 4591 for value in ["", ".", "..", "a/b", "/absolute", "a\\b"] { 4592 assert!(!valid_output_component(value), "{value}"); 4593 } 4594 assert!(!valid_output_component(&"a".repeat(129))); 4595 } 4596 4597 #[test] 4598 fn output_scope_and_target_admission_are_fail_closed() { 4599 let fixture = ReleaseFixture::new(); 4600 assert_eq!( 4601 fixture.write(&fixture.service.join("release")), 4602 Err(ReleaseArtifactError::InvalidOutputRoot) 4603 ); 4604 assert_eq!( 4605 fixture.write(&fixture.input.join("release")), 4606 Err(ReleaseArtifactError::InvalidOutputRoot) 4607 ); 4608 assert_eq!( 4609 run_inner(Arguments { 4610 mode: CommandMode::Write, 4611 service_root: &fixture.service, 4612 lib_root: &fixture.lib, 4613 input_root: &fixture.input, 4614 output_root: &fixture.output_a, 4615 target: "x86_64-apple-darwin", 4616 source_date_epoch: 1_700_000_000, 4617 candidate_digest: &"a".repeat(64), 4618 }), 4619 Err(ReleaseArtifactError::InvalidServiceMetadata) 4620 ); 4621 assert_eq!( 4622 run_inner(Arguments { 4623 mode: CommandMode::Write, 4624 service_root: &fixture.service, 4625 lib_root: &fixture.lib, 4626 input_root: &fixture.input, 4627 output_root: &fixture.output_a, 4628 target: "x86_64-unknown-linux-gnu", 4629 source_date_epoch: 0, 4630 candidate_digest: &"a".repeat(64), 4631 }), 4632 Err(ReleaseArtifactError::InvalidServiceMetadata) 4633 ); 4634 assert_eq!( 4635 run_inner(Arguments { 4636 mode: CommandMode::Write, 4637 service_root: &fixture.service, 4638 lib_root: &fixture.lib, 4639 input_root: &fixture.input, 4640 output_root: &fixture.output_a, 4641 target: native_fixture_target(), 4642 source_date_epoch: 1_700_000_000, 4643 candidate_digest: &"A".repeat(64), 4644 }), 4645 Err(ReleaseArtifactError::InvalidServiceMetadata) 4646 ); 4647 } 4648 4649 #[test] 4650 fn secret_scanner_detects_a_pattern_across_chunk_boundaries() { 4651 let mut scanner = SecretScanner::default(); 4652 scanner.scan(b"prefix -----BEGIN OPENSSH").expect("prefix"); 4653 assert_eq!( 4654 scanner.scan( 4655 b" PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END OPENSSH PRIVATE KEY----- suffix" 4656 ), 4657 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4658 ); 4659 } 4660 4661 #[test] 4662 fn release_metadata_rejects_well_typed_but_invalid_identifiers_and_versions() { 4663 let temporary = TempDir::new().unwrap(); 4664 let original: toml::Value = toml::from_str( 4665 r#" 4666 [workspace.package] 4667 license = "AGPL-3.0-or-later" 4668 [workspace.metadata.radroots.service_release] 4669 service = "myc" 4670 service_package = "fixture-service" 4671 binary_name = "fixture-service" 4672 version = "0.1.0-alpha" 4673 "#, 4674 ) 4675 .unwrap(); 4676 let manifest = temporary.path().join("Cargo.toml"); 4677 fs::write(&manifest, toml::to_string(&original).unwrap()).unwrap(); 4678 read_release_metadata(temporary.path()).unwrap(); 4679 for (field, replacement) in [ 4680 ("service", "Invalid".to_owned()), 4681 ("service_package", "Invalid".to_owned()), 4682 ("binary_name", "../escape".to_owned()), 4683 ("version", "x".repeat(129)), 4684 ("version", "invalid".to_owned()), 4685 ("version", "01.0.0".to_owned()), 4686 ] { 4687 let mut value = original.clone(); 4688 value["workspace"]["metadata"]["radroots"]["service_release"][field] = 4689 toml::Value::String(replacement); 4690 fs::write(&manifest, toml::to_string(&value).unwrap()).unwrap(); 4691 assert!(matches!( 4692 read_release_metadata(temporary.path()), 4693 Err(ReleaseArtifactError::InvalidServiceMetadata) 4694 )); 4695 } 4696 let mut value = original; 4697 value["workspace"]["package"]["license"] = toml::Value::String("MIT".to_owned()); 4698 fs::write(&manifest, toml::to_string(&value).unwrap()).unwrap(); 4699 assert!(matches!( 4700 read_release_metadata(temporary.path()), 4701 Err(ReleaseArtifactError::InvalidServiceMetadata) 4702 )); 4703 } 4704 4705 #[test] 4706 fn sbom_dependency_edges_composition_and_artifact_hashes_are_exact() { 4707 let artifacts = vec![ArtifactRecord { 4708 path: "binary.tar.gz".to_owned(), 4709 byte_length: 42, 4710 sha256: "a".repeat(64), 4711 }]; 4712 for case in 0..7 { 4713 let (mut sbom, _, _) = 4714 build_supply_chain_documents(&sample_metadata(), sample_cargo_metadata()).unwrap(); 4715 reconcile_sbom_artifacts(&mut sbom, &artifacts); 4716 validate_cyclonedx_profile(&sbom, &artifacts).unwrap(); 4717 match case { 4718 0 => sbom.components[0].bom_ref = sbom.metadata.component.bom_ref.clone(), 4719 1 => { 4720 sbom.dependencies.pop(); 4721 } 4722 2 => sbom.dependencies[0].reference = "unknown".to_owned(), 4723 3 => sbom.dependencies[0].depends_on.push("unknown".to_owned()), 4724 4 => sbom.compositions[0].aggregate = "incomplete", 4725 5 => sbom.compositions[0].assemblies.clear(), 4726 _ => { 4727 let artifact = sbom 4728 .components 4729 .iter_mut() 4730 .find(|row| row.bom_ref.starts_with("artifact:")) 4731 .unwrap(); 4732 artifact.hashes[0].content = "b".repeat(64); 4733 } 4734 } 4735 assert_eq!( 4736 validate_cyclonedx_profile(&sbom, &artifacts), 4737 Err(ReleaseArtifactError::GenerationFailure), 4738 "SBOM case {case}" 4739 ); 4740 } 4741 } 4742 4743 #[test] 4744 fn schema_reconciliation_attribution_and_subjects_fail_closed() { 4745 let (mut sbom, _, _) = 4746 build_supply_chain_documents(&sample_metadata(), sample_cargo_metadata()) 4747 .expect("supply-chain documents"); 4748 let artifacts = vec![ArtifactRecord { 4749 path: "binary.tar.gz".to_owned(), 4750 byte_length: 42, 4751 sha256: "a".repeat(64), 4752 }]; 4753 reconcile_sbom_artifacts(&mut sbom, &artifacts); 4754 validate_cyclonedx_profile(&sbom, &artifacts).expect("CycloneDX profile"); 4755 sbom.spec_version = "1.5"; 4756 assert_eq!( 4757 validate_cyclonedx_profile(&sbom, &artifacts), 4758 Err(ReleaseArtifactError::GenerationFailure) 4759 ); 4760 sbom.spec_version = "1.6"; 4761 sbom.components.pop(); 4762 assert_eq!( 4763 validate_cyclonedx_profile(&sbom, &artifacts), 4764 Err(ReleaseArtifactError::GenerationFailure) 4765 ); 4766 4767 let mut cargo = sample_cargo_metadata(); 4768 cargo.packages[1].license_texts[0].sha256 = "b".repeat(64); 4769 assert!(matches!( 4770 build_supply_chain_documents(&sample_metadata(), cargo), 4771 Err(ReleaseArtifactError::InvalidPackageInventory) 4772 )); 4773 4774 let mut provenance = build_provenance( 4775 ProvenanceInput { 4776 candidate_digest: &"a".repeat(64), 4777 service: "myc", 4778 target: native_fixture_target(), 4779 source_date_epoch: 1_700_000_000, 4780 service_repository: "https://github.com/radrootslabs/mycelium", 4781 service_revision: &"1".repeat(40), 4782 lib_revision: &"2".repeat(40), 4783 source_lock_sha256: &"3".repeat(64), 4784 manifest_sha256: &"4".repeat(64), 4785 }, 4786 &artifacts, 4787 ); 4788 validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts) 4789 .expect("exact provenance subjects"); 4790 provenance.statement_type = "unknown"; 4791 assert!(validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts).is_err()); 4792 provenance.statement_type = "https://in-toto.io/Statement/v1"; 4793 provenance.predicate_type = "unknown"; 4794 assert!(validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts).is_err()); 4795 provenance.predicate_type = "https://slsa.dev/provenance/v1"; 4796 assert!(validate_provenance_subjects(&provenance, &"b".repeat(64), &artifacts).is_err()); 4797 provenance.subject.clear(); 4798 assert_eq!( 4799 validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts), 4800 Err(ReleaseArtifactError::GenerationFailure) 4801 ); 4802 4803 let scan_evidence = FileEvidence { 4804 byte_length: 7, 4805 sha256: "5".repeat(64), 4806 }; 4807 let scan = ScanDocument { 4808 schema: "radroots.service.artifact-scan.v1", 4809 contract_version: 1, 4810 candidate_digest: "a".repeat(64), 4811 state: "no_protected_material_detected", 4812 ruleset_sha256: "6".repeat(64), 4813 scanned_artifacts: artifacts.clone(), 4814 nested_members_scanned: 1, 4815 expanded_bytes_scanned: 42, 4816 }; 4817 for case in 0..6 { 4818 let mut manifest = ArtifactManifestDocument { 4819 schema: "radroots.service.release-artifacts.v2", 4820 contract_version: 2, 4821 candidate_digest: "a".repeat(64), 4822 service: "myc".to_owned(), 4823 version: "0.1.0-alpha".to_owned(), 4824 target: native_fixture_target().to_owned(), 4825 source_date_epoch: 1_700_000_000, 4826 service_revision: "1".repeat(40), 4827 lib_revision: "2".repeat(40), 4828 rust_version: "1.97.1", 4829 host_feature_profile: "service-host", 4830 contract_versions: ContractVersionsDocument { 4831 config: 1, 4832 state: 1, 4833 admin: 1, 4834 status: 1, 4835 provider: 1, 4836 }, 4837 confidentiality: ConfidentialityDocument { 4838 state: scan.state, 4839 derived_from: artifact_record("artifact-scan.v1.json", &scan_evidence), 4840 protected_material_included: false, 4841 }, 4842 artifacts: artifacts.clone(), 4843 }; 4844 validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &artifacts) 4845 .expect("derived confidentiality"); 4846 match case { 4847 0 => manifest.confidentiality.state = "invented_clean_state", 4848 1 => manifest.candidate_digest = "b".repeat(64), 4849 2 => manifest.confidentiality.protected_material_included = true, 4850 3 => manifest.confidentiality.derived_from.path = "unknown".to_owned(), 4851 4 => manifest.confidentiality.derived_from.sha256 = "b".repeat(64), 4852 _ => manifest.artifacts.clear(), 4853 } 4854 assert_eq!( 4855 validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &artifacts), 4856 Err(ReleaseArtifactError::GenerationFailure) 4857 ); 4858 } 4859 } 4860 4861 #[test] 4862 fn secret_path_and_history_archive_vectors_fail_closed() { 4863 let root = TempDir::new().expect("archive vector root"); 4864 let mut token = b"ghp_".to_vec(); 4865 token.extend(std::iter::repeat_n(b'A', 36)); 4866 4867 let source = root.path().join("source.tar"); 4868 write_tar_fixture(&source, "src/value.bin", &token, false); 4869 assert_eq!( 4870 scan_tar_members(&source, false), 4871 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4872 ); 4873 4874 let sensitive = root.path().join("sensitive.tar"); 4875 write_tar_fixture(&sensitive, ".git/config", b"clean", false); 4876 assert_eq!( 4877 scan_tar_members(&sensitive, false), 4878 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4879 ); 4880 4881 let binary = root.path().join("binary.tar.gz"); 4882 write_tar_fixture(&binary, "bin/service", &token, true); 4883 assert_eq!( 4884 scan_tar_gzip_members(&binary), 4885 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4886 ); 4887 4888 let layer = root.path().join("layer.tar"); 4889 write_tar_fixture(&layer, "nix/store/service", &token, false); 4890 assert_eq!( 4891 scan_tar_members(&layer, true), 4892 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4893 ); 4894 4895 let bundle = root.path().join("history.bundle"); 4896 write_file(&bundle, b"not an exact-tree archive"); 4897 assert_eq!( 4898 scan_tar_members(&bundle, false), 4899 Err(ReleaseArtifactError::InvalidInputArtifact) 4900 ); 4901 } 4902 4903 #[test] 4904 fn archive_scanning_enforces_member_types_counts_lengths_and_protected_paths() { 4905 let root = TempDir::new().unwrap(); 4906 let path = root.path().join("input.tar"); 4907 for kind in [ 4908 tar::EntryType::Directory, 4909 tar::EntryType::Symlink, 4910 tar::EntryType::Link, 4911 tar::EntryType::Fifo, 4912 ] { 4913 let mut builder = TarBuilder::new(Vec::new()); 4914 let mut header = TarHeader::new_gnu(); 4915 header.set_path("member").unwrap(); 4916 header.set_entry_type(kind); 4917 header.set_size(0); 4918 if kind.is_symlink() || kind.is_hard_link() { 4919 header.set_link_name("target").unwrap(); 4920 } 4921 header.set_cksum(); 4922 builder.append(&header, &[][..]).unwrap(); 4923 let bytes = builder.into_inner().unwrap(); 4924 fs::write(&path, &bytes).unwrap(); 4925 assert!(scan_tar_members(&path, false).is_err()); 4926 assert_eq!( 4927 scan_tar_members(&path, true).is_ok(), 4928 kind != tar::EntryType::Fifo 4929 ); 4930 let gzip = root.path().join("binary.tar.gz"); 4931 let mut encoder = GzBuilder::new().write(Vec::new(), Compression::best()); 4932 encoder.write_all(&bytes).unwrap(); 4933 fs::write(&gzip, encoder.finish().unwrap()).unwrap(); 4934 assert_eq!(scan_tar_gzip_members(&gzip).is_ok(), kind.is_dir()); 4935 } 4936 let empty = TarBuilder::new(Vec::new()).into_inner().unwrap(); 4937 fs::write(&path, &empty).unwrap(); 4938 assert!(scan_tar_members(&path, true).is_err()); 4939 let mut encoder = GzBuilder::new().write(Vec::new(), Compression::best()); 4940 encoder.write_all(&empty).unwrap(); 4941 fs::write(&path, encoder.finish().unwrap()).unwrap(); 4942 assert!(scan_tar_gzip_members(&path).is_err()); 4943 for length in [2, 4] { 4944 assert!(scan_reader(&mut &b"abc"[..], length).is_err()); 4945 } 4946 assert_eq!(scan_reader(&mut &b"abc"[..], 3).unwrap(), 3); 4947 for path in [ 4948 "/absolute", 4949 "../escape", 4950 "src/.ssh/config", 4951 &"a".repeat(MAX_ARCHIVE_PATH_BYTES + 1), 4952 ] { 4953 assert_eq!( 4954 validate_scanned_path(path), 4955 Err(ReleaseArtifactError::ProtectedMaterialDetected) 4956 ); 4957 } 4958 } 4959 4960 fn write_tar_fixture(path: &Path, member: &str, bytes: &[u8], gzip: bool) { 4961 let mut header = TarHeader::new_gnu(); 4962 header.set_entry_type(tar::EntryType::Regular); 4963 header.set_size(bytes.len() as u64); 4964 header.set_mode(0o644); 4965 header.set_uid(0); 4966 header.set_gid(0); 4967 header.set_mtime(1); 4968 header.set_cksum(); 4969 if gzip { 4970 let output = fs::File::create(path).expect("gzip tar fixture"); 4971 let encoder = GzBuilder::new() 4972 .mtime(1) 4973 .operating_system(255) 4974 .write(output, Compression::best()); 4975 let mut archive = TarBuilder::new(encoder); 4976 archive 4977 .append_data(&mut header, member, bytes) 4978 .expect("gzip tar member"); 4979 let encoder = archive.into_inner().expect("gzip tar archive"); 4980 encoder.finish().expect("gzip tar finish"); 4981 } else { 4982 let output = fs::File::create(path).expect("tar fixture"); 4983 let mut archive = TarBuilder::new(output); 4984 archive 4985 .append_data(&mut header, member, bytes) 4986 .expect("tar member"); 4987 archive.finish().expect("tar finish"); 4988 } 4989 } 4990 4991 #[test] 4992 fn identifiers_and_sources_are_closed() { 4993 assert!(valid_snake_identifier("fixture_service")); 4994 assert!(!valid_snake_identifier("fixture__service")); 4995 assert!(valid_kebab_identifier("fixture-service")); 4996 assert!(!valid_kebab_identifier("fixture--service")); 4997 assert!(valid_public_source( 4998 "registry+https://github.com/rust-lang/crates.io-index" 4999 )); 5000 assert!(valid_public_source( 5001 "git+https://github.com/radrootslabs/lib?rev=1111111111111111111111111111111111111111#1111111111111111111111111111111111111111" 5002 )); 5003 assert!(!valid_public_source( 5004 "git+ssh://git@github.com/private/repo" 5005 )); 5006 assert!(!valid_public_source("path+file:///secret")); 5007 } 5008 5009 #[test] 5010 fn errors_are_stable_and_source_free() { 5011 let errors = [ 5012 ReleaseArtifactError::InvalidContract, 5013 ReleaseArtifactError::InvalidServiceRoot, 5014 ReleaseArtifactError::DirtyServiceSource, 5015 ReleaseArtifactError::InvalidServiceMetadata, 5016 ReleaseArtifactError::InvalidInputRoot, 5017 ReleaseArtifactError::InvalidInputArtifact, 5018 ReleaseArtifactError::InvalidSourceLock, 5019 ReleaseArtifactError::InvalidSourceBundle, 5020 ReleaseArtifactError::InvalidPackageInventory, 5021 ReleaseArtifactError::ProtectedMaterialDetected, 5022 ReleaseArtifactError::InvalidOutputRoot, 5023 ReleaseArtifactError::StaleOutput, 5024 ReleaseArtifactError::GenerationFailure, 5025 ]; 5026 for error in errors { 5027 let display = error.to_string(); 5028 let debug = format!("{error:?}"); 5029 assert!(!display.contains('/')); 5030 assert!(!display.contains("secret")); 5031 assert!(!debug.contains('/')); 5032 assert!(std::error::Error::source(&error).is_none()); 5033 } 5034 } 5035 }