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