myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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 }