rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

main.rs (67426B)


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