lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 5245d7fdf487ac905f2ddd47f635d349f9911465
parent e56509b9dd3d0695f138d59aa524f64c9911a6c4
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 00:05:38 +0000

build: add service release artifacts

Diffstat:
MAGENTS.md | 9+++++++++
Acontracts/architecture/decisions/services_hardening_release_artifacts.v1.json | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/README | 5+++++
Mtools/xtask/src/main.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/service_release_artifacts.rs | 2261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/service_source_lock.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 2506 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -52,6 +52,15 @@ This file exists for compatibility with tools that look for AGENTS.md. Lib dependency, the Cargo lock, the direct revision-pinned Nix input, the source archive, and the canonical public remote must agree. The command rejects every source-tree change except the exact generated lock path. +- Generate or verify one immutable service release artifact set with `cargo + xtask service-release-artifacts --mode write|check --service-root + <absolute-directory> --input-root <absolute-directory> --output-root + <absolute-directory> --target <rust-target> --source-date-epoch <seconds>`. + The command consumes the exact fixed input inventory, validates clean and + stable service and Lib source bundles, and emits the canonical binary + archive, OCI/source metadata, CycloneDX SBOM, notices, manifest, unsigned + provenance signing input, and checksums. Signing credentials and signatures + remain external; generated artifacts must contain no protected material. - Current source and tests are implementation evidence. They do not silently override `radroots.crates.release.v1`. - Record any evidence-based plan deviation in diff --git a/contracts/architecture/decisions/services_hardening_release_artifacts.v1.json b/contracts/architecture/decisions/services_hardening_release_artifacts.v1.json @@ -0,0 +1,103 @@ +{ + "schema": "radroots.services-hardening.release-artifacts-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "command": "cargo xtask service-release-artifacts", + "modes": [ + "check", + "write" + ], + "required_arguments": [ + "mode", + "service_root", + "input_root", + "output_root", + "target", + "source_date_epoch" + ], + "service_metadata_path": "Cargo.toml.workspace.metadata.radroots.service_release", + "service_metadata_fields": [ + "service", + "service_package", + "binary_name", + "version" + ], + "supported_targets": [ + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "input_inventory": [ + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "service-binary", + "service-source.bundle", + "systemd.service" + ], + "excluded_parent_owned_inputs": [ + "backup_restore_runbook", + "operator_runbook" + ], + "service_root_inventory": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "radroots.service.source-lock.v1.toml" + ], + "output_inventory": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "SHA256SUMS", + "THIRD-PARTY-NOTICES.txt", + "artifact-manifest.v1.json", + "binary.tar.gz", + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "oci-image.v1.json", + "provenance-input.v1.json", + "radroots.service.source-lock.v1.toml", + "sbom.cdx.json", + "service-source.bundle", + "source-bundles.v1.json", + "systemd.service" + ], + "canonical_json": "compact_utf8_json_with_one_final_lf", + "checksum_format": "sha256_lower_hex_two_spaces_path_lf_sorted_by_path", + "sbom_format": "cyclonedx_json_1_5_locked_cargo_graph", + "provenance_posture": "deterministic_unsigned_slsa_v1_signing_input_external_keys_only", + "protected_material_scan_scope": "all_textual_release_inputs_and_generated_documents", + "source_cleanliness": "no_tracked_staged_or_untracked_changes", + "revision_stability": "same_service_head_before_and_after_generation", + "no_protected_material": true, + "maximums": { + "text_input_bytes": 1048576, + "generated_document_bytes": 16777216, + "service_cargo_lock_bytes": 16777216, + "service_flake_lock_bytes": 4194304, + "binary_bytes": 536870912, + "source_bundle_bytes": 1073741824, + "oci_bytes": 2147483648, + "cargo_metadata_bytes": 33554432, + "packages": 8192, + "workspace_packages": 64 + }, + "negative_error_codes": [ + "invalid_contract", + "invalid_service_root", + "dirty_service_source", + "invalid_service_metadata", + "invalid_input_root", + "invalid_input_artifact", + "invalid_source_lock", + "invalid_source_bundle", + "invalid_package_inventory", + "protected_material_detected", + "invalid_output_root", + "stale_output", + "generation_failure" + ] +} diff --git a/tools/xtask/README b/tools/xtask/README @@ -17,6 +17,11 @@ tasks for the `radroots` core libraries. * strict `service-source-lock --mode write|check` generation and verification for standalone hardened services, including Cargo, Nix, source-archive, toolchain, cleanliness, and public-revision agreement; + * deterministic `service-release-artifacts --mode write|check` assembly for + the fixed hardened-service binary, OCI, configuration, module, license, + source-bundle, SBOM, notice, manifest, provenance-input, and checksum + inventory, with signing credentials kept external and operator runbooks + deliberately excluded from this standalone build input set; * command-dispatch code used for contract, coverage, hygiene, and release paths inside the workspace; * a non-published binary crate used as tooling rather than as a library diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -38,6 +38,7 @@ mod release_qualification; mod safety_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod sdk_generation; +mod service_release_artifacts; mod service_source_lock; mod service_source_lock_command; #[cfg_attr(coverage_nightly, coverage(off))] @@ -125,6 +126,20 @@ enum XtaskCommand { #[arg(long)] source_archive: PathBuf, }, + ServiceReleaseArtifacts { + #[arg(long, value_enum)] + mode: ServiceReleaseArtifactMode, + #[arg(long)] + service_root: PathBuf, + #[arg(long)] + input_root: PathBuf, + #[arg(long)] + output_root: PathBuf, + #[arg(long)] + target: String, + #[arg(long)] + source_date_epoch: u32, + }, Source { #[command(subcommand)] command: SourceCommand, @@ -185,6 +200,12 @@ enum ServiceSourceLockMode { } #[derive(Clone, Copy, Debug, ValueEnum)] +enum ServiceReleaseArtifactMode { + Check, + Write, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] enum ArtifactProduct { Sdk, Mobile, @@ -321,6 +342,9 @@ fn usage() { " cargo xtask service-source-lock --mode <check|write> --service-root <absolute-directory> --source-archive <absolute-bundle>" ); eprintln!( + " cargo xtask service-release-artifacts --mode <check|write> --service-root <absolute-directory> --input-root <absolute-directory> --output-root <absolute-directory> --target <rust-target> --source-date-epoch <seconds>" + ); + eprintln!( " cargo xtask source materialize --consumer-root <absolute-directory> --cache-root <absolute-directory> --mode <prefetch|offline>" ); eprintln!(" cargo xtask source archive-verify --archive <bundle> --sha256 <digest>"); @@ -367,6 +391,7 @@ fn validate_contract() -> Result<(), String> { validate_protocol_contracts()?; let root = workspace_root(); service_source_lock::validate_contract(&root)?; + service_release_artifacts::validate_contract(&root)?; dto_roots::check(&root)?; generate::protocol::check(&root)?; contract::load_contract_bundle(&root) @@ -384,6 +409,7 @@ fn release_preflight() -> Result<(), String> { fn release_preflight_at(root: &Path) -> Result<(), String> { catalog::check(root)?; service_source_lock::validate_contract(root)?; + service_release_artifacts::validate_contract(root)?; for group in ["public_native", "preview", "tools"] { build_control::group_plan(root, group, build_control::Operation::Check, false)?; } @@ -500,6 +526,24 @@ fn run(args: &[String]) -> Result<(), String> { &service_root, &source_archive, ), + XtaskCommand::ServiceReleaseArtifacts { + mode, + service_root, + input_root, + output_root, + target, + source_date_epoch, + } => service_release_artifacts::run( + match mode { + ServiceReleaseArtifactMode::Check => service_release_artifacts::CommandMode::Check, + ServiceReleaseArtifactMode::Write => service_release_artifacts::CommandMode::Write, + }, + &service_root, + &input_root, + &output_root, + &target, + source_date_epoch, + ), XtaskCommand::Source { command } => match command { SourceCommand::Materialize { consumer_root, @@ -674,6 +718,42 @@ mod tests { ]) .is_ok() ); + assert!( + Cli::try_parse_from([ + "xtask", + "service-release-artifacts", + "--mode", + "write", + "--service-root", + "/tmp/service", + "--input-root", + "/tmp/input", + "--output-root", + "/tmp/output", + "--target", + "x86_64-unknown-linux-gnu", + ]) + .is_err() + ); + assert!( + Cli::try_parse_from([ + "xtask", + "service-release-artifacts", + "--mode", + "check", + "--service-root", + "/tmp/service", + "--input-root", + "/tmp/input", + "--output-root", + "/tmp/output", + "--target", + "x86_64-unknown-linux-gnu", + "--source-date-epoch", + "1", + ]) + .is_ok() + ); assert!( Cli::try_parse_from([ diff --git a/tools/xtask/src/service_release_artifacts.rs b/tools/xtask/src/service_release_artifacts.rs @@ -0,0 +1,2261 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::OsStr, + fmt, fs, + io::{Read as _, Write as _}, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, +}; + +use flate2::{Compression, GzBuilder}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tar::{Builder as TarBuilder, Header as TarHeader}; +use tempfile::TempDir; + +use crate::service_source_lock::{LIB_REPOSITORY, LOCK_FILENAME, ServiceSourceLockV1}; + +const CONTRACT_RELATIVE: &str = + "contracts/architecture/decisions/services_hardening_release_artifacts.v1.json"; +const INPUT_NAMES: [&str; 8] = [ + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "service-binary", + "service-source.bundle", + "systemd.service", +]; +const OUTPUT_NAMES: [&str; 18] = [ + "LICENSE-APACHE", + "LICENSE-MIT", + "SHA256SUMS", + "THIRD-PARTY-NOTICES.txt", + "artifact-manifest.v1.json", + "binary.tar.gz", + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "oci-image.v1.json", + "provenance-input.v1.json", + "radroots.service.source-lock.v1.toml", + "sbom.cdx.json", + "service-source.bundle", + "source-bundles.v1.json", + "systemd.service", +]; +const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]; +const SECRET_PATTERNS: [&[u8]; 7] = [ + b"-----BEGIN PRIVATE KEY-----", + b"-----BEGIN RSA PRIVATE KEY-----", + b"-----BEGIN EC PRIVATE KEY-----", + b"-----BEGIN OPENSSH PRIVATE KEY-----", + b"github_pat_", + b"ghp_", + b"xoxb-", +]; +const MAX_CONTRACT_BYTES: usize = 65_536; +const MAX_MANIFEST_BYTES: usize = 1_048_576; +const MAX_TEXT_INPUT_BYTES: u64 = 1_048_576; +const MAX_GENERATED_DOCUMENT_BYTES: u64 = 16_777_216; +const MAX_SOURCE_LOCK_BYTES: u64 = 4_096; +const MAX_SERVICE_CARGO_LOCK_BYTES: u64 = 16_777_216; +const MAX_SERVICE_FLAKE_LOCK_BYTES: u64 = 4_194_304; +const MAX_BINARY_BYTES: u64 = 536_870_912; +const MAX_SOURCE_BUNDLE_BYTES: u64 = 1_073_741_824; +const MAX_OCI_BYTES: u64 = 2_147_483_648; +const MAX_METADATA_BYTES: usize = 33_554_432; +const MAX_GIT_OUTPUT_BYTES: usize = 65_536; +const MAX_PACKAGES: usize = 8_192; +const MAX_WORKSPACE_PACKAGES: usize = 64; +const MAX_TEXT_FIELD_BYTES: usize = 512; +const FILE_MODE: u32 = 0o644; +const DIRECTORY_MODE: u32 = 0o755; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CommandMode { + Check, + Write, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReleaseArtifactError { + InvalidContract, + InvalidServiceRoot, + DirtyServiceSource, + InvalidServiceMetadata, + InvalidInputRoot, + InvalidInputArtifact, + InvalidSourceLock, + InvalidSourceBundle, + InvalidPackageInventory, + ProtectedMaterialDetected, + InvalidOutputRoot, + StaleOutput, + GenerationFailure, +} + +impl ReleaseArtifactError { + const fn code(self) -> &'static str { + match self { + Self::InvalidContract => "invalid_contract", + Self::InvalidServiceRoot => "invalid_service_root", + Self::DirtyServiceSource => "dirty_service_source", + Self::InvalidServiceMetadata => "invalid_service_metadata", + Self::InvalidInputRoot => "invalid_input_root", + Self::InvalidInputArtifact => "invalid_input_artifact", + Self::InvalidSourceLock => "invalid_source_lock", + Self::InvalidSourceBundle => "invalid_source_bundle", + Self::InvalidPackageInventory => "invalid_package_inventory", + Self::ProtectedMaterialDetected => "protected_material_detected", + Self::InvalidOutputRoot => "invalid_output_root", + Self::StaleOutput => "stale_output", + Self::GenerationFailure => "generation_failure", + } + } +} + +impl fmt::Display for ReleaseArtifactError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidContract => "service release artifact contract is invalid", + Self::InvalidServiceRoot => "service release source root is invalid", + Self::DirtyServiceSource => "service release source contains an ungoverned change", + Self::InvalidServiceMetadata => "service release metadata is invalid", + Self::InvalidInputRoot => "service release input root is invalid", + Self::InvalidInputArtifact => "service release input artifact is invalid", + Self::InvalidSourceLock => "service release source lock is invalid", + Self::InvalidSourceBundle => "service release source bundle is invalid", + Self::InvalidPackageInventory => "service release package inventory is invalid", + Self::ProtectedMaterialDetected => "service release input contains protected material", + Self::InvalidOutputRoot => "service release output root is invalid", + Self::StaleOutput => "service release artifact set is stale", + Self::GenerationFailure => "service release artifacts could not be generated", + }) + } +} + +impl std::error::Error for ReleaseArtifactError {} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseMetadata { + service: String, + service_package: String, + binary_name: String, + version: String, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec<CargoPackage>, + workspace_members: Vec<String>, + resolve: Option<CargoResolve>, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + id: String, + name: String, + version: String, + source: Option<String>, + checksum: Option<String>, + license: Option<String>, + targets: Vec<CargoTarget>, +} + +#[derive(Debug, Deserialize)] +struct CargoTarget { + name: String, + kind: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CargoResolve { + nodes: Vec<CargoNode>, +} + +#[derive(Debug, Deserialize)] +struct CargoNode { + id: String, + dependencies: Vec<String>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct DigestValue { + alg: &'static str, + content: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct LicenseChoice { + expression: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct SbomComponent { + #[serde(rename = "type")] + component_type: &'static str, + #[serde(rename = "bom-ref")] + bom_ref: String, + name: String, + version: String, + purl: String, + licenses: Vec<LicenseChoice>, + #[serde(skip_serializing_if = "Vec::is_empty")] + hashes: Vec<DigestValue>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct SbomDependency { + #[serde(rename = "ref")] + reference: String, + depends_on: Vec<String>, +} + +#[derive(Debug, Serialize)] +struct SbomMetadata { + component: SbomComponent, +} + +#[derive(Debug, Serialize)] +struct CycloneDxSbom { + #[serde(rename = "bomFormat")] + bom_format: &'static str, + #[serde(rename = "specVersion")] + spec_version: &'static str, + version: u32, + metadata: SbomMetadata, + components: Vec<SbomComponent>, + dependencies: Vec<SbomDependency>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct ArtifactRecord { + path: String, + byte_length: u64, + sha256: String, +} + +#[derive(Debug, Serialize)] +struct ContractVersionsDocument { + config: u32, + state: u32, + admin: u32, + status: u32, + provider: u32, +} + +#[derive(Debug, Serialize)] +struct SourceBundleDocument { + schema: &'static str, + contract_version: u32, + service: String, + service_revision: String, + lib_repository: &'static str, + lib_revision: String, + service_source: ArtifactRecord, + lib_source: ArtifactRecord, + source_lock_sha256: String, + workspace_catalog_sha256: String, + cargo_lock_sha256: String, + flake_lock_sha256: String, +} + +#[derive(Debug, Serialize)] +struct OciImageDocument { + schema: &'static str, + contract_version: u32, + service: String, + version: String, + target: String, + image: ArtifactRecord, +} + +#[derive(Debug, Serialize)] +struct ArtifactManifestDocument { + schema: &'static str, + contract_version: u32, + service: String, + version: String, + target: String, + source_date_epoch: u32, + service_revision: String, + lib_revision: String, + rust_version: &'static str, + host_feature_profile: &'static str, + contract_versions: ContractVersionsDocument, + protected_material_included: bool, + artifacts: Vec<ArtifactRecord>, +} + +#[derive(Debug, Serialize)] +struct ProvenanceInputDocument { + schema: &'static str, + contract_version: u32, + predicate_type: &'static str, + build_type: &'static str, + builder_id: &'static str, + service: String, + version: String, + target: String, + source_date_epoch: u32, + service_repository: String, + service_revision: String, + lib_repository: &'static str, + lib_revision: String, + source_lock_sha256: String, + manifest_sha256: String, + subjects: Vec<ArtifactRecord>, + signing_required: bool, +} + +#[derive(Clone, Debug)] +struct FileEvidence { + byte_length: u64, + sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseDecision { + schema: String, + contract_version: u32, + decision_state: String, + command: String, + modes: Vec<String>, + required_arguments: Vec<String>, + service_metadata_path: String, + service_metadata_fields: Vec<String>, + supported_targets: Vec<String>, + input_inventory: Vec<String>, + excluded_parent_owned_inputs: Vec<String>, + service_root_inventory: Vec<String>, + output_inventory: Vec<String>, + canonical_json: String, + checksum_format: String, + sbom_format: String, + provenance_posture: String, + protected_material_scan_scope: String, + source_cleanliness: String, + revision_stability: String, + no_protected_material: bool, + maximums: ReleaseMaximums, + negative_error_codes: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseMaximums { + text_input_bytes: u64, + generated_document_bytes: u64, + service_cargo_lock_bytes: u64, + service_flake_lock_bytes: u64, + binary_bytes: u64, + source_bundle_bytes: u64, + oci_bytes: u64, + cargo_metadata_bytes: usize, + packages: usize, + workspace_packages: usize, +} + +pub(crate) fn run( + mode: CommandMode, + service_root: &Path, + input_root: &Path, + output_root: &Path, + target: &str, + source_date_epoch: u32, +) -> Result<(), String> { + run_inner( + mode, + service_root, + input_root, + output_root, + target, + source_date_epoch, + ) + .map_err(|error| error.to_string()) +} + +fn run_inner( + mode: CommandMode, + service_root: &Path, + input_root: &Path, + output_root: &Path, + target: &str, + source_date_epoch: u32, +) -> Result<(), ReleaseArtifactError> { + if !SUPPORTED_TARGETS.contains(&target) || source_date_epoch == 0 { + return Err(ReleaseArtifactError::InvalidServiceMetadata); + } + let service_root = validate_git_root(service_root)?; + let input_root = validate_exact_input_root(input_root)?; + let (output_parent, output_root) = + validate_output_parent(output_root, &service_root, &input_root)?; + let initial_head = git_head(&service_root)?; + validate_clean_git(&service_root)?; + + let metadata = read_release_metadata(&service_root)?; + let source_lock_bytes = read_bounded_regular( + &service_root.join(LOCK_FILENAME), + MAX_SOURCE_LOCK_BYTES, + ReleaseArtifactError::InvalidSourceLock, + )?; + let source_lock = ServiceSourceLockV1::from_canonical_bytes(&source_lock_bytes) + .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; + if source_lock.service() != metadata.service { + return Err(ReleaseArtifactError::InvalidSourceLock); + } + validate_source_lock_files(&service_root, &source_lock)?; + let cargo_metadata = cargo_metadata(&service_root)?; + let (sbom, notices) = build_supply_chain_documents(&metadata, cargo_metadata)?; + + let staging = tempfile::Builder::new() + .prefix(".radroots-service-release-") + .tempdir_in(&output_parent) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + set_directory_mode(staging.path())?; + + let static_inputs = [ + ("config.example.toml", "config.example.toml"), + ("config.schema.json", "config.schema.json"), + ("systemd.service", "systemd.service"), + ("nixos-module.nix", "nixos-module.nix"), + ]; + for (input, output) in static_inputs { + copy_bounded( + &input_root.join(input), + &staging.path().join(output), + MAX_TEXT_INPUT_BYTES, + )?; + validate_text_artifact(&staging.path().join(output), output)?; + } + copy_bounded( + &service_root.join("LICENSE-APACHE"), + &staging.path().join("LICENSE-APACHE"), + MAX_TEXT_INPUT_BYTES, + )?; + validate_text_artifact(&staging.path().join("LICENSE-APACHE"), "LICENSE-APACHE")?; + copy_bounded( + &service_root.join("LICENSE-MIT"), + &staging.path().join("LICENSE-MIT"), + MAX_TEXT_INPUT_BYTES, + )?; + validate_text_artifact(&staging.path().join("LICENSE-MIT"), "LICENSE-MIT")?; + write_generated(&staging.path().join(LOCK_FILENAME), &source_lock_bytes)?; + create_binary_archive( + &input_root.join("service-binary"), + &staging.path().join("binary.tar.gz"), + &metadata.binary_name, + source_date_epoch, + )?; + let oci = copy_bounded( + &input_root.join("oci-image.tar.gz"), + &staging.path().join("oci-image.tar.gz"), + MAX_OCI_BYTES, + )?; + let service_source = copy_bounded( + &input_root.join("service-source.bundle"), + &staging.path().join("service-source.bundle"), + MAX_SOURCE_BUNDLE_BYTES, + )?; + let lib_source = copy_bounded( + &input_root.join("lib-source.bundle"), + &staging.path().join("lib-source.bundle"), + MAX_SOURCE_BUNDLE_BYTES, + )?; + verify_bundle(&staging.path().join("service-source.bundle"), &initial_head)?; + verify_bundle( + &staging.path().join("lib-source.bundle"), + source_lock.revision(), + )?; + if lib_source.sha256 != source_lock.source_archive_sha256() { + return Err(ReleaseArtifactError::InvalidSourceBundle); + } + + let oci_document = OciImageDocument { + schema: "radroots.service.oci-image.v1", + contract_version: 1, + service: metadata.service.clone(), + version: metadata.version.clone(), + target: target.to_owned(), + image: artifact_record("oci-image.tar.gz", &oci), + }; + write_json(&staging.path().join("oci-image.v1.json"), &oci_document)?; + let source_lock_sha256 = sha256_bytes(&source_lock_bytes); + let source_document = SourceBundleDocument { + schema: "radroots.service.source-bundles.v1", + contract_version: 1, + service: metadata.service.clone(), + service_revision: initial_head.clone(), + lib_repository: LIB_REPOSITORY, + lib_revision: source_lock.revision().to_owned(), + service_source: artifact_record("service-source.bundle", &service_source), + lib_source: artifact_record("lib-source.bundle", &lib_source), + source_lock_sha256: source_lock_sha256.clone(), + workspace_catalog_sha256: source_lock.workspace_catalog_sha256().to_owned(), + cargo_lock_sha256: source_lock.cargo_lock_sha256().to_owned(), + flake_lock_sha256: source_lock.flake_lock_sha256().to_owned(), + }; + write_json( + &staging.path().join("source-bundles.v1.json"), + &source_document, + )?; + write_json(&staging.path().join("sbom.cdx.json"), &sbom)?; + write_generated( + &staging.path().join("THIRD-PARTY-NOTICES.txt"), + notices.as_bytes(), + )?; + + let payload = inventory_records(staging.path())?; + let versions = source_lock.contract_versions(); + let manifest = ArtifactManifestDocument { + schema: "radroots.service.release-artifacts.v1", + contract_version: 1, + service: metadata.service.clone(), + version: metadata.version.clone(), + target: target.to_owned(), + source_date_epoch, + service_revision: initial_head.clone(), + lib_revision: source_lock.revision().to_owned(), + rust_version: "1.97.1", + host_feature_profile: "service-host", + contract_versions: ContractVersionsDocument { + config: versions.config(), + state: versions.state(), + admin: versions.admin(), + status: versions.status(), + provider: versions.provider(), + }, + protected_material_included: false, + artifacts: payload, + }; + write_json(&staging.path().join("artifact-manifest.v1.json"), &manifest)?; + let manifest_evidence = hash_regular( + &staging.path().join("artifact-manifest.v1.json"), + MAX_TEXT_INPUT_BYTES, + )?; + let service_repository = git_remote(&service_root)?; + let provenance = ProvenanceInputDocument { + schema: "radroots.service.provenance-input.v1", + contract_version: 1, + predicate_type: "https://slsa.dev/provenance/v1", + build_type: "https://radroots.dev/contracts/service-release-artifacts/v1", + builder_id: "https://radroots.dev/builders/service-release-artifacts/v1", + service: metadata.service, + version: metadata.version, + target: target.to_owned(), + source_date_epoch, + service_repository, + service_revision: initial_head.clone(), + lib_repository: LIB_REPOSITORY, + lib_revision: source_lock.revision().to_owned(), + source_lock_sha256, + manifest_sha256: manifest_evidence.sha256, + subjects: inventory_records(staging.path())?, + signing_required: true, + }; + write_json( + &staging.path().join("provenance-input.v1.json"), + &provenance, + )?; + write_checksums(staging.path())?; + validate_exact_output_inventory(staging.path())?; + sync_directory(staging.path())?; + let expected_output = inventory_records(staging.path())?; + + validate_clean_git(&service_root)?; + if git_head(&service_root)? != initial_head { + return Err(ReleaseArtifactError::DirtyServiceSource); + } + + if output_root.exists() { + compare_output(staging.path(), &output_root)?; + return Ok(()); + } + if mode == CommandMode::Check { + return Err(ReleaseArtifactError::StaleOutput); + } + let staging_path = staging.keep(); + fs::rename(&staging_path, &output_root).map_err(|_| ReleaseArtifactError::GenerationFailure)?; + sync_directory(&output_parent)?; + validate_output_records(&output_root, &expected_output) +} + +fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifactError> { + let bytes = read_bounded_regular( + &root.join("Cargo.toml"), + MAX_MANIFEST_BYTES as u64, + ReleaseArtifactError::InvalidServiceMetadata, + )?; + let text = + std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; + let value = toml::from_str::<toml::Value>(text) + .map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; + let release = value + .get("workspace") + .and_then(|value| value.get("metadata")) + .and_then(|value| value.get("radroots")) + .and_then(|value| value.get("service_release")) + .cloned() + .ok_or(ReleaseArtifactError::InvalidServiceMetadata)?; + let metadata = release + .try_into::<ReleaseMetadata>() + .map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; + if !valid_snake_identifier(&metadata.service) + || !valid_kebab_identifier(&metadata.service_package) + || !valid_kebab_identifier(&metadata.binary_name) + || metadata.version.len() > 128 + || !matches!( + semver::Version::parse(&metadata.version), + Ok(version) if version.to_string() == metadata.version + ) + { + return Err(ReleaseArtifactError::InvalidServiceMetadata); + } + Ok(metadata) +} + +fn validate_source_lock_files( + root: &Path, + source_lock: &ServiceSourceLockV1, +) -> Result<(), ReleaseArtifactError> { + let cargo_lock = hash_regular(&root.join("Cargo.lock"), MAX_SERVICE_CARGO_LOCK_BYTES) + .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; + let flake_lock = hash_regular(&root.join("flake.lock"), MAX_SERVICE_FLAKE_LOCK_BYTES) + .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; + if cargo_lock.sha256 == source_lock.cargo_lock_sha256() + && flake_lock.sha256 == source_lock.flake_lock_sha256() + { + Ok(()) + } else { + Err(ReleaseArtifactError::InvalidSourceLock) + } +} + +fn cargo_metadata(root: &Path) -> Result<CargoMetadata, ReleaseArtifactError> { + let mut command = Command::new("cargo"); + command + .args(["metadata", "--format-version", "1", "--locked", "--offline"]) + .current_dir(root); + let bytes = command_stdout(&mut command, MAX_METADATA_BYTES) + .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?; + serde_json::from_slice(&bytes).map_err(|_| ReleaseArtifactError::InvalidPackageInventory) +} + +fn build_supply_chain_documents( + metadata: &ReleaseMetadata, + cargo: CargoMetadata, +) -> Result<(CycloneDxSbom, String), ReleaseArtifactError> { + if cargo.packages.is_empty() + || cargo.packages.len() > MAX_PACKAGES + || cargo.workspace_members.is_empty() + || cargo.workspace_members.len() > MAX_WORKSPACE_PACKAGES + { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + let workspace = cargo.workspace_members.into_iter().collect::<BTreeSet<_>>(); + let mut by_id = BTreeMap::new(); + let mut root_id = None; + for package in cargo.packages { + validate_metadata_package(&package, workspace.contains(&package.id))?; + if package.name == metadata.service_package + && (root_id.replace(package.id.clone()).is_some() + || package.version != metadata.version + || !package.targets.iter().any(|target| { + target.name == metadata.binary_name + && target.kind.iter().any(|kind| kind == "bin") + }) + || !workspace.contains(&package.id)) + { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + if by_id.insert(package.id.clone(), package).is_some() { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + } + let root_id = root_id.ok_or(ReleaseArtifactError::InvalidPackageInventory)?; + if by_id.len() > MAX_PACKAGES || !workspace.iter().all(|id| by_id.contains_key(id)) { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + let references = by_id + .values() + .map(|package| (package.id.clone(), package_reference(package))) + .collect::<BTreeMap<_, _>>(); + let root_package = by_id + .get(&root_id) + .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; + let root_component = sbom_component(root_package, "application")?; + let mut components = by_id + .values() + .filter(|package| package.id != root_id) + .map(|package| sbom_component(package, "library")) + .collect::<Result<Vec<_>, _>>()?; + components.sort(); + let resolve = cargo + .resolve + .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; + if resolve.nodes.len() != by_id.len() || resolve.nodes.len() > MAX_PACKAGES { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + let mut seen_nodes = BTreeSet::new(); + let mut dependencies = Vec::with_capacity(resolve.nodes.len()); + for node in resolve.nodes { + if !seen_nodes.insert(node.id.clone()) { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + let reference = references + .get(&node.id) + .ok_or(ReleaseArtifactError::InvalidPackageInventory)? + .clone(); + let mut depends_on = node + .dependencies + .iter() + .map(|dependency| { + references + .get(dependency) + .cloned() + .ok_or(ReleaseArtifactError::InvalidPackageInventory) + }) + .collect::<Result<Vec<_>, _>>()?; + depends_on.sort(); + depends_on.dedup(); + dependencies.push(SbomDependency { + reference, + depends_on, + }); + } + dependencies.sort_by(|left, right| left.reference.cmp(&right.reference)); + let sbom = CycloneDxSbom { + bom_format: "CycloneDX", + spec_version: "1.5", + version: 1, + metadata: SbomMetadata { + component: root_component, + }, + components, + dependencies, + }; + + let mut third_party = by_id + .values() + .filter(|package| !workspace.contains(&package.id)) + .collect::<Vec<_>>(); + third_party.sort_by(|left, right| { + (&left.name, &left.version, &left.source).cmp(&(&right.name, &right.version, &right.source)) + }); + let mut notices = String::from( + "Radroots service third-party notices v1\n\nThis inventory is generated from the locked Cargo dependency graph.\n", + ); + if third_party.is_empty() { + notices.push_str("\nNo third-party Cargo packages are present.\n"); + } else { + for package in third_party { + let license = package + .license + .as_deref() + .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; + let source = package + .source + .as_deref() + .ok_or(ReleaseArtifactError::InvalidPackageInventory)?; + use fmt::Write as _; + writeln!(notices).map_err(|_| ReleaseArtifactError::GenerationFailure)?; + writeln!(notices, "Package: {} {}", package.name, package.version) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + writeln!(notices, "License: {license}") + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + writeln!(notices, "Source: {source}") + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + } + } + scan_bytes(notices.as_bytes())?; + Ok((sbom, notices)) +} + +fn validate_metadata_package( + package: &CargoPackage, + workspace_member: bool, +) -> Result<(), ReleaseArtifactError> { + for value in [&package.name, &package.version, &package.id] { + if value.is_empty() || value.len() > MAX_TEXT_FIELD_BYTES || value.contains(['\n', '\r']) { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + } + if package + .license + .as_ref() + .is_some_and(|value| !valid_metadata_text(value)) + || package + .source + .as_ref() + .is_some_and(|value| !valid_public_source(value)) + || (!workspace_member && (package.source.is_none() || package.license.is_none())) + || package + .checksum + .as_ref() + .is_some_and(|value| !valid_lower_hex(value, 64)) + { + return Err(ReleaseArtifactError::InvalidPackageInventory); + } + Ok(()) +} + +fn sbom_component( + package: &CargoPackage, + component_type: &'static str, +) -> Result<SbomComponent, ReleaseArtifactError> { + let license = package + .license + .clone() + .unwrap_or_else(|| "NOASSERTION".to_owned()); + let hashes = package + .checksum + .iter() + .map(|checksum| DigestValue { + alg: "SHA-256", + content: checksum.clone(), + }) + .collect(); + Ok(SbomComponent { + component_type, + bom_ref: package_reference(package), + name: package.name.clone(), + version: package.version.clone(), + purl: format!("pkg:cargo/{}@{}", package.name, package.version), + licenses: vec![LicenseChoice { + expression: license, + }], + hashes, + }) +} + +fn package_reference(package: &CargoPackage) -> String { + let source = package.source.as_deref().unwrap_or("workspace"); + let source_digest = sha256_bytes(source.as_bytes()); + format!( + "cargo:{}@{}#{}", + package.name, + package.version, + &source_digest[..16] + ) +} + +fn validate_exact_input_root(path: &Path) -> Result<PathBuf, ReleaseArtifactError> { + let root = validate_absolute_directory(path, ReleaseArtifactError::InvalidInputRoot)?; + let inventory = directory_inventory(&root, ReleaseArtifactError::InvalidInputRoot)?; + if inventory == INPUT_NAMES.into_iter().map(str::to_owned).collect() { + Ok(root) + } else { + Err(ReleaseArtifactError::InvalidInputRoot) + } +} + +fn validate_output_parent( + output: &Path, + service_root: &Path, + input_root: &Path, +) -> Result<(PathBuf, PathBuf), ReleaseArtifactError> { + if !output.is_absolute() + || output + .file_name() + .and_then(OsStr::to_str) + .is_none_or(|name| !valid_output_component(name)) + { + return Err(ReleaseArtifactError::InvalidOutputRoot); + } + let parent = output + .parent() + .ok_or(ReleaseArtifactError::InvalidOutputRoot)?; + let parent = validate_absolute_directory(parent, ReleaseArtifactError::InvalidOutputRoot)?; + let output = parent.join( + output + .file_name() + .ok_or(ReleaseArtifactError::InvalidOutputRoot)?, + ); + if output.starts_with(service_root) + || output.starts_with(input_root) + || service_root.starts_with(&output) + || input_root.starts_with(&output) + { + return Err(ReleaseArtifactError::InvalidOutputRoot); + } + if let Ok(metadata) = fs::symlink_metadata(&output) + && (metadata.file_type().is_symlink() || !metadata.is_dir()) + { + return Err(ReleaseArtifactError::InvalidOutputRoot); + } + Ok((parent, output)) +} + +fn validate_git_root(path: &Path) -> Result<PathBuf, ReleaseArtifactError> { + let root = validate_absolute_directory(path, ReleaseArtifactError::InvalidServiceRoot)?; + let top = git_stdout( + &root, + ["rev-parse", "--show-toplevel"], + MAX_GIT_OUTPUT_BYTES, + ) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; + let top = std::str::from_utf8(&top) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? + .trim(); + let top = fs::canonicalize(top).map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; + if top == root { + Ok(root) + } else { + Err(ReleaseArtifactError::InvalidServiceRoot) + } +} + +fn validate_absolute_directory( + path: &Path, + error: ReleaseArtifactError, +) -> Result<PathBuf, ReleaseArtifactError> { + if !path.is_absolute() { + return Err(error); + } + let metadata = fs::symlink_metadata(path).map_err(|_| error)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(error); + } + fs::canonicalize(path).map_err(|_| error) +} + +fn validate_clean_git(root: &Path) -> Result<(), ReleaseArtifactError> { + let status = git_stdout( + root, + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + MAX_GIT_OUTPUT_BYTES, + ) + .map_err(|_| ReleaseArtifactError::DirtyServiceSource)?; + if status.is_empty() { + Ok(()) + } else { + Err(ReleaseArtifactError::DirtyServiceSource) + } +} + +fn git_head(root: &Path) -> Result<String, ReleaseArtifactError> { + let bytes = git_stdout(root, ["rev-parse", "HEAD"], 128) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; + let value = std::str::from_utf8(&bytes) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? + .trim(); + if valid_lower_hex(value, 40) { + Ok(value.to_owned()) + } else { + Err(ReleaseArtifactError::InvalidServiceRoot) + } +} + +fn git_remote(root: &Path) -> Result<String, ReleaseArtifactError> { + let bytes = git_stdout(root, ["remote", "get-url", "origin"], MAX_GIT_OUTPUT_BYTES) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)?; + let value = std::str::from_utf8(&bytes) + .map_err(|_| ReleaseArtifactError::InvalidServiceRoot)? + .trim(); + if value.len() > MAX_TEXT_FIELD_BYTES + || !(value.starts_with("https://github.com/") || value.starts_with("ssh://git@github.com/")) + || value.contains(['\n', '\r']) + { + return Err(ReleaseArtifactError::InvalidServiceRoot); + } + Ok(value.to_owned()) +} + +fn verify_bundle(path: &Path, revision: &str) -> Result<(), ReleaseArtifactError> { + let verification = TempDir::new().map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; + git_status(verification.path(), ["init", "--bare", "--quiet"]) + .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; + let output = Command::new("git") + .args(["bundle", "verify"]) + .arg(path) + .current_dir(verification.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; + if !output.success() { + return Err(ReleaseArtifactError::InvalidSourceBundle); + } + let mut command = Command::new("git"); + command.args(["bundle", "list-heads"]).arg(path); + let heads = command_stdout(&mut command, MAX_GIT_OUTPUT_BYTES) + .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?; + if heads == format!("{revision} refs/heads/archive\n").as_bytes() { + Ok(()) + } else { + Err(ReleaseArtifactError::InvalidSourceBundle) + } +} + +fn create_binary_archive( + source: &Path, + output: &Path, + binary_name: &str, + source_date_epoch: u32, +) -> Result<FileEvidence, ReleaseArtifactError> { + let source_metadata = validate_regular_input(source, MAX_BINARY_BYTES)?; + hash_regular(source, MAX_BINARY_BYTES)?; + let mut source_file = + fs::File::open(source).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + let output_file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(output) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + let encoder = GzBuilder::new() + .mtime(source_date_epoch) + .operating_system(255) + .write(output_file, Compression::best()); + let mut archive = TarBuilder::new(encoder); + let mut header = TarHeader::new_gnu(); + header.set_size(source_metadata.len()); + header.set_mode(0o755); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(u64::from(source_date_epoch)); + header.set_cksum(); + archive + .append_data(&mut header, format!("bin/{binary_name}"), &mut source_file) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + let encoder = archive + .into_inner() + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + let output_file = encoder + .finish() + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + output_file + .sync_all() + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + set_file_mode(output)?; + validate_unchanged_input(source, &source_metadata)?; + hash_regular(output, MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES) +} + +fn copy_bounded( + source: &Path, + output: &Path, + maximum: u64, +) -> Result<FileEvidence, ReleaseArtifactError> { + let source_metadata = validate_regular_input(source, maximum)?; + let mut input = + fs::File::open(source).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + let mut output_file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(output) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut buffer = [0_u8; 65_536]; + loop { + let read = input + .read(&mut buffer) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if read == 0 { + break; + } + total = total + .checked_add(read as u64) + .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; + if total > maximum { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + hasher.update(&buffer[..read]); + output_file + .write_all(&buffer[..read]) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + } + if total != source_metadata.len() { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + output_file + .sync_all() + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + set_file_mode(output)?; + validate_unchanged_input(source, &source_metadata)?; + Ok(FileEvidence { + byte_length: total, + sha256: hex::encode(hasher.finalize()), + }) +} + +#[derive(Default)] +struct SecretScanner { + tail: Vec<u8>, +} + +impl SecretScanner { + fn scan(&mut self, bytes: &[u8]) -> Result<(), ReleaseArtifactError> { + let mut combined = Vec::with_capacity(self.tail.len() + bytes.len()); + combined.extend_from_slice(&self.tail); + combined.extend_from_slice(bytes); + if SECRET_PATTERNS + .iter() + .any(|pattern| contains_bytes(&combined, pattern)) + { + return Err(ReleaseArtifactError::ProtectedMaterialDetected); + } + let retained = SECRET_PATTERNS + .iter() + .map(|pattern| pattern.len().saturating_sub(1)) + .max() + .unwrap_or(0) + .min(combined.len()); + self.tail.clear(); + self.tail + .extend_from_slice(&combined[combined.len() - retained..]); + Ok(()) + } +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn scan_bytes(bytes: &[u8]) -> Result<(), ReleaseArtifactError> { + let mut scanner = SecretScanner::default(); + scanner.scan(bytes) +} + +fn validate_text_artifact(path: &Path, name: &str) -> Result<(), ReleaseArtifactError> { + let bytes = read_bounded_regular( + path, + MAX_TEXT_INPUT_BYTES, + ReleaseArtifactError::InvalidInputArtifact, + )?; + std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if bytes.contains(&0) { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + scan_bytes(&bytes)?; + if name == "config.schema.json" { + serde_json::from_slice::<serde_json::Value>(&bytes) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + } else if name == "config.example.toml" { + toml::from_str::<toml::Value>( + std::str::from_utf8(&bytes).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?, + ) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + } + Ok(()) +} + +fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ReleaseArtifactError> { + let mut bytes = + serde_json::to_vec(value).map_err(|_| ReleaseArtifactError::GenerationFailure)?; + bytes.push(b'\n'); + write_generated(path, &bytes) +} + +fn write_generated(path: &Path, bytes: &[u8]) -> Result<(), ReleaseArtifactError> { + if bytes.is_empty() || bytes.len() as u64 > MAX_GENERATED_DOCUMENT_BYTES { + return Err(ReleaseArtifactError::GenerationFailure); + } + scan_bytes(bytes)?; + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + set_file_mode(path) +} + +fn write_checksums(root: &Path) -> Result<(), ReleaseArtifactError> { + let mut records = inventory_records(root)?; + records.sort(); + let mut checksums = String::new(); + use fmt::Write as _; + for record in records { + writeln!(checksums, "{} {}", record.sha256, record.path) + .map_err(|_| ReleaseArtifactError::GenerationFailure)?; + } + write_generated(&root.join("SHA256SUMS"), checksums.as_bytes()) +} + +fn inventory_records(root: &Path) -> Result<Vec<ArtifactRecord>, ReleaseArtifactError> { + let mut names = directory_inventory(root, ReleaseArtifactError::GenerationFailure)? + .into_iter() + .collect::<Vec<_>>(); + names.sort(); + names + .into_iter() + .map(|name| { + let maximum = output_maximum(&name)?; + let evidence = hash_regular(&root.join(&name), maximum)?; + Ok(artifact_record(&name, &evidence)) + }) + .collect() +} + +fn artifact_record(path: &str, evidence: &FileEvidence) -> ArtifactRecord { + ArtifactRecord { + path: path.to_owned(), + byte_length: evidence.byte_length, + sha256: evidence.sha256.clone(), + } +} + +fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> { + match name { + "binary.tar.gz" => Ok(MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES), + "oci-image.tar.gz" => Ok(MAX_OCI_BYTES), + "service-source.bundle" | "lib-source.bundle" => Ok(MAX_SOURCE_BUNDLE_BYTES), + "LICENSE-APACHE" + | "LICENSE-MIT" + | "config.example.toml" + | "config.schema.json" + | "nixos-module.nix" + | "radroots.service.source-lock.v1.toml" + | "systemd.service" => Ok(MAX_TEXT_INPUT_BYTES), + "SHA256SUMS" + | "THIRD-PARTY-NOTICES.txt" + | "artifact-manifest.v1.json" + | "oci-image.v1.json" + | "provenance-input.v1.json" + | "sbom.cdx.json" + | "source-bundles.v1.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES), + _ => Err(ReleaseArtifactError::GenerationFailure), + } +} + +fn hash_regular(path: &Path, maximum: u64) -> Result<FileEvidence, ReleaseArtifactError> { + let metadata = + fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + let mut file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut buffer = [0_u8; 65_536]; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if read == 0 { + break; + } + total = total + .checked_add(read as u64) + .ok_or(ReleaseArtifactError::InvalidInputArtifact)?; + if total > maximum { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + hasher.update(&buffer[..read]); + } + if total != metadata.len() { + return Err(ReleaseArtifactError::InvalidInputArtifact); + } + Ok(FileEvidence { + byte_length: total, + sha256: hex::encode(hasher.finalize()), + }) +} + +fn read_bounded_regular( + path: &Path, + maximum: u64, + error: ReleaseArtifactError, +) -> Result<Vec<u8>, ReleaseArtifactError> { + let metadata = fs::symlink_metadata(path).map_err(|_| error)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum { + return Err(error); + } + let capacity = usize::try_from(metadata.len()).map_err(|_| error)?; + let mut bytes = Vec::with_capacity(capacity); + fs::File::open(path) + .map_err(|_| error)? + .take(maximum.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| error)?; + if bytes.len() as u64 > maximum { + Err(error) + } else { + Ok(bytes) + } +} + +fn validate_regular_input(path: &Path, maximum: u64) -> Result<fs::Metadata, ReleaseArtifactError> { + let metadata = + fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > maximum + { + Err(ReleaseArtifactError::InvalidInputArtifact) + } else { + Ok(metadata) + } +} + +#[cfg(unix)] +fn validate_unchanged_input( + path: &Path, + expected: &fs::Metadata, +) -> Result<(), ReleaseArtifactError> { + use std::os::unix::fs::MetadataExt as _; + let current = + fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if current.file_type().is_symlink() + || !current.is_file() + || current.dev() != expected.dev() + || current.ino() != expected.ino() + || current.len() != expected.len() + { + Err(ReleaseArtifactError::InvalidInputArtifact) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn validate_unchanged_input( + path: &Path, + expected: &fs::Metadata, +) -> Result<(), ReleaseArtifactError> { + let current = + fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; + if current.file_type().is_symlink() || !current.is_file() || current.len() != expected.len() { + Err(ReleaseArtifactError::InvalidInputArtifact) + } else { + Ok(()) + } +} + +fn directory_inventory( + root: &Path, + error: ReleaseArtifactError, +) -> Result<BTreeSet<String>, ReleaseArtifactError> { + let mut names = BTreeSet::new(); + for entry in fs::read_dir(root).map_err(|_| error)? { + let entry = entry.map_err(|_| error)?; + let file_type = entry.file_type().map_err(|_| error)?; + if file_type.is_symlink() || !file_type.is_file() { + return Err(error); + } + let name = entry.file_name().into_string().map_err(|_| error)?; + if !names.insert(name) || names.len() > OUTPUT_NAMES.len() { + return Err(error); + } + } + Ok(names) +} + +fn validate_exact_output_inventory(root: &Path) -> Result<(), ReleaseArtifactError> { + let expected = OUTPUT_NAMES.into_iter().map(str::to_owned).collect(); + if directory_inventory(root, ReleaseArtifactError::GenerationFailure)? != expected { + return Err(ReleaseArtifactError::GenerationFailure); + } + validate_directory_mode(root)?; + for name in OUTPUT_NAMES { + validate_file_mode(&root.join(name))?; + } + Ok(()) +} + +fn compare_output(expected: &Path, actual: &Path) -> Result<(), ReleaseArtifactError> { + let actual = validate_absolute_directory(actual, ReleaseArtifactError::StaleOutput)?; + if directory_inventory(&actual, ReleaseArtifactError::StaleOutput)? + != OUTPUT_NAMES.into_iter().map(str::to_owned).collect() + { + return Err(ReleaseArtifactError::StaleOutput); + } + validate_directory_mode(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; + for name in OUTPUT_NAMES { + validate_file_mode(&actual.join(name)).map_err(|_| ReleaseArtifactError::StaleOutput)?; + let maximum = output_maximum(name)?; + let left = hash_regular(&expected.join(name), maximum) + .map_err(|_| ReleaseArtifactError::StaleOutput)?; + let right = hash_regular(&actual.join(name), maximum) + .map_err(|_| ReleaseArtifactError::StaleOutput)?; + if left.byte_length != right.byte_length || left.sha256 != right.sha256 { + return Err(ReleaseArtifactError::StaleOutput); + } + } + Ok(()) +} + +fn validate_output_records( + actual: &Path, + expected: &[ArtifactRecord], +) -> Result<(), ReleaseArtifactError> { + let actual = validate_absolute_directory(actual, ReleaseArtifactError::StaleOutput)?; + validate_exact_output_inventory(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; + let actual_records = + inventory_records(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?; + if actual_records == expected { + Ok(()) + } else { + Err(ReleaseArtifactError::StaleOutput) + } +} + +#[cfg(unix)] +fn set_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)) + .map_err(|_| ReleaseArtifactError::GenerationFailure) +} + +#[cfg(not(unix))] +fn set_file_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { + Ok(()) +} + +#[cfg(unix)] +fn set_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(DIRECTORY_MODE)) + .map_err(|_| ReleaseArtifactError::GenerationFailure) +} + +#[cfg(not(unix))] +fn set_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { + Ok(()) +} + +#[cfg(unix)] +fn validate_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::symlink_metadata(path) + .map_err(|_| ReleaseArtifactError::StaleOutput)? + .permissions() + .mode() + & 0o777; + if mode == FILE_MODE { + Ok(()) + } else { + Err(ReleaseArtifactError::StaleOutput) + } +} + +#[cfg(not(unix))] +fn validate_file_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { + Ok(()) +} + +#[cfg(unix)] +fn validate_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::symlink_metadata(path) + .map_err(|_| ReleaseArtifactError::StaleOutput)? + .permissions() + .mode() + & 0o777; + if mode == DIRECTORY_MODE { + Ok(()) + } else { + Err(ReleaseArtifactError::StaleOutput) + } +} + +#[cfg(not(unix))] +fn validate_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> { + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<(), ReleaseArtifactError> { + fs::File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|_| ReleaseArtifactError::GenerationFailure) +} + +fn command_stdout(command: &mut Command, maximum: usize) -> Result<Vec<u8>, ()> { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| ())?; + let mut stdout = child.stdout.take().ok_or(())?; + let mut bytes = Vec::new(); + if stdout + .by_ref() + .take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .is_err() + || bytes.len() > maximum + { + let _ = child.kill(); + let _ = child.wait(); + return Err(()); + } + let status = child.wait().map_err(|_| ())?; + if status.success() { Ok(bytes) } else { Err(()) } +} + +fn git_stdout<const N: usize>(root: &Path, args: [&str; N], maximum: usize) -> Result<Vec<u8>, ()> { + let mut command = Command::new("git"); + command.args(args).current_dir(root); + command_stdout(&mut command, maximum) +} + +fn git_status<const N: usize>(root: &Path, args: [&str; N]) -> Result<(), ()> { + let status = Command::new("git") + .args(args) + .current_dir(root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|_| ())?; + if status.success() { Ok(()) } else { Err(()) } +} + +fn valid_metadata_text(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_TEXT_FIELD_BYTES + && !value.contains(['\n', '\r']) + && scan_bytes(value.as_bytes()).is_ok() +} + +fn valid_public_source(value: &str) -> bool { + valid_metadata_text(value) + && (value.starts_with("registry+https://") || value.starts_with("git+https://")) + && !value.contains("@github.com") +} + +fn valid_snake_identifier(value: &str) -> bool { + value.len() <= 128 + && value + .split('_') + .all(|segment| !segment.is_empty() && valid_lower_alphanumeric(segment)) + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn valid_kebab_identifier(value: &str) -> bool { + value.len() <= 128 + && value + .split('-') + .all(|segment| !segment.is_empty() && valid_lower_alphanumeric(segment)) + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn valid_lower_alphanumeric(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn valid_output_component(value: &str) -> bool { + let mut components = Path::new(value).components(); + !value.is_empty() + && value.len() <= 128 + && !value.contains(['/', '\\']) + && matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() + && value != "." + && value != ".." +} + +fn valid_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { + validate_contract_inner(workspace_root).map_err(|error| error.to_string()) +} + +fn validate_contract_inner(workspace_root: &Path) -> Result<(), ReleaseArtifactError> { + let bytes = read_bounded_regular( + &workspace_root.join(CONTRACT_RELATIVE), + MAX_CONTRACT_BYTES as u64, + ReleaseArtifactError::InvalidContract, + )?; + let decision = serde_json::from_slice::<ReleaseDecision>(&bytes) + .map_err(|_| ReleaseArtifactError::InvalidContract)?; + let errors = [ + ReleaseArtifactError::InvalidContract, + ReleaseArtifactError::InvalidServiceRoot, + ReleaseArtifactError::DirtyServiceSource, + ReleaseArtifactError::InvalidServiceMetadata, + ReleaseArtifactError::InvalidInputRoot, + ReleaseArtifactError::InvalidInputArtifact, + ReleaseArtifactError::InvalidSourceLock, + ReleaseArtifactError::InvalidSourceBundle, + ReleaseArtifactError::InvalidPackageInventory, + ReleaseArtifactError::ProtectedMaterialDetected, + ReleaseArtifactError::InvalidOutputRoot, + ReleaseArtifactError::StaleOutput, + ReleaseArtifactError::GenerationFailure, + ]; + if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v1" + || decision.contract_version != 1 + || decision.decision_state != "active" + || decision.command != "cargo xtask service-release-artifacts" + || decision.modes != ["check", "write"] + || decision.required_arguments + != [ + "mode", + "service_root", + "input_root", + "output_root", + "target", + "source_date_epoch", + ] + || decision.service_metadata_path + != "Cargo.toml.workspace.metadata.radroots.service_release" + || decision.service_metadata_fields + != ["service", "service_package", "binary_name", "version"] + || decision.supported_targets != SUPPORTED_TARGETS + || decision.input_inventory != INPUT_NAMES + || decision.excluded_parent_owned_inputs != ["backup_restore_runbook", "operator_runbook"] + || decision.service_root_inventory != ["LICENSE-APACHE", "LICENSE-MIT", LOCK_FILENAME] + || decision.output_inventory != OUTPUT_NAMES + || decision.canonical_json != "compact_utf8_json_with_one_final_lf" + || decision.checksum_format != "sha256_lower_hex_two_spaces_path_lf_sorted_by_path" + || decision.sbom_format != "cyclonedx_json_1_5_locked_cargo_graph" + || decision.provenance_posture + != "deterministic_unsigned_slsa_v1_signing_input_external_keys_only" + || decision.protected_material_scan_scope + != "all_textual_release_inputs_and_generated_documents" + || decision.source_cleanliness != "no_tracked_staged_or_untracked_changes" + || decision.revision_stability != "same_service_head_before_and_after_generation" + || !decision.no_protected_material + || decision.maximums.text_input_bytes != MAX_TEXT_INPUT_BYTES + || decision.maximums.generated_document_bytes != MAX_GENERATED_DOCUMENT_BYTES + || decision.maximums.service_cargo_lock_bytes != MAX_SERVICE_CARGO_LOCK_BYTES + || decision.maximums.service_flake_lock_bytes != MAX_SERVICE_FLAKE_LOCK_BYTES + || decision.maximums.binary_bytes != MAX_BINARY_BYTES + || decision.maximums.source_bundle_bytes != MAX_SOURCE_BUNDLE_BYTES + || decision.maximums.oci_bytes != MAX_OCI_BYTES + || decision.maximums.cargo_metadata_bytes != MAX_METADATA_BYTES + || decision.maximums.packages != MAX_PACKAGES + || decision.maximums.workspace_packages != MAX_WORKSPACE_PACKAGES + || decision.negative_error_codes != errors.map(ReleaseArtifactError::code) + { + return Err(ReleaseArtifactError::InvalidContract); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{io::Read as _, process::Command}; + + use crate::service_source_lock::{ + ContractVersions, ServiceSourceLockParts, ServiceSourceLockV1, + }; + + use super::*; + + struct ReleaseFixture { + _root: TempDir, + service: PathBuf, + input: PathBuf, + output_a: PathBuf, + output_b: PathBuf, + } + + impl ReleaseFixture { + fn new() -> Self { + let root = TempDir::new().expect("fixture root"); + let service = root.path().join("service"); + let lib = root.path().join("lib"); + let input = root.path().join("input"); + fs::create_dir_all(service.join("src")).expect("service source"); + fs::create_dir_all(lib.join("contracts/crates")).expect("Lib contracts"); + fs::create_dir(&input).expect("input root"); + + write_file( + &lib.join("contracts/crates/catalog.v2.toml"), + b"schema_version = 2\n", + ); + write_file(&lib.join("README.md"), b"fixture Lib source\n"); + initialize_git(&lib, "https://github.com/radrootslabs/lib"); + let lib_revision = git_output(&lib, &["rev-parse", "HEAD"]); + create_bundle(&lib, &input.join("lib-source.bundle")); + let lib_bundle = fs::read(input.join("lib-source.bundle")).expect("Lib bundle"); + let catalog = + fs::read(lib.join("contracts/crates/catalog.v2.toml")).expect("workspace catalog"); + + write_file( + &service.join("Cargo.toml"), + br#"[package] +name = "fixture-service" +version = "0.1.0-alpha" +edition = "2024" + +[[bin]] +name = "fixture-service" +path = "src/main.rs" + +[workspace] +resolver = "3" + +[workspace.metadata.radroots.service_release] +service = "fixture_service" +service_package = "fixture-service" +binary_name = "fixture-service" +version = "0.1.0-alpha" +"#, + ); + write_file(&service.join("src/main.rs"), b"fn main() {}\n"); + write_file( + &service.join("Cargo.lock"), + br#"# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fixture-service" +version = "0.1.0-alpha" +"#, + ); + write_file( + &service.join("flake.lock"), + b"{\"nodes\":{},\"root\":\"root\",\"version\":7}\n", + ); + write_file( + &service.join("LICENSE-APACHE"), + b"Apache-2.0 fixture license\n", + ); + write_file(&service.join("LICENSE-MIT"), b"MIT fixture license\n"); + let cargo_lock = fs::read(service.join("Cargo.lock")).expect("Cargo lock"); + let flake_lock = fs::read(service.join("flake.lock")).expect("flake lock"); + let source_lock = ServiceSourceLockV1::new(ServiceSourceLockParts { + service: "fixture_service", + revision: &lib_revision, + workspace_catalog_sha256: &sha256_bytes(&catalog), + source_archive_sha256: &sha256_bytes(&lib_bundle), + cargo_lock_sha256: &sha256_bytes(&cargo_lock), + flake_lock_sha256: &sha256_bytes(&flake_lock), + contract_versions: ContractVersions::new(1, 1, 1, 1, 1), + }) + .expect("source lock"); + write_file(&service.join(LOCK_FILENAME), source_lock.canonical_bytes()); + initialize_git(&service, "https://github.com/radrootslabs/fixture-service"); + create_bundle(&service, &input.join("service-source.bundle")); + + for (name, bytes) in [ + ("config.example.toml", b"enabled = true\n".as_slice()), + ("config.schema.json", b"{\"type\":\"object\"}\n".as_slice()), + ("nixos-module.nix", b"{ ... }: {}\n".as_slice()), + ( + "systemd.service", + b"[Service]\nExecStart=/usr/bin/fixture-service\n".as_slice(), + ), + ] { + write_file(&input.join(name), bytes); + } + write_file( + &input.join("service-binary"), + b"fixture service executable\0\xff", + ); + write_file( + &input.join("oci-image.tar.gz"), + b"fixture OCI archive\0\xff", + ); + + Self { + output_a: root.path().join("release-a"), + output_b: root.path().join("release-b"), + _root: root, + service, + input, + } + } + + fn write(&self, output: &Path) -> Result<(), ReleaseArtifactError> { + run_inner( + CommandMode::Write, + &self.service, + &self.input, + output, + "x86_64-unknown-linux-gnu", + 1_700_000_000, + ) + } + + fn check(&self, output: &Path) -> Result<(), ReleaseArtifactError> { + run_inner( + CommandMode::Check, + &self.service, + &self.input, + output, + "x86_64-unknown-linux-gnu", + 1_700_000_000, + ) + } + } + + fn write_file(path: &Path, bytes: &[u8]) { + fs::write(path, bytes).expect("write fixture file"); + } + + fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(root) + .status() + .expect("run Git"); + assert!(status.success(), "git {args:?}"); + } + + fn git_output(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("run Git"); + assert!(output.status.success(), "git {args:?}"); + String::from_utf8(output.stdout) + .expect("Git UTF-8") + .trim() + .to_owned() + } + + fn initialize_git(root: &Path, remote: &str) { + git(root, &["init", "--quiet"]); + git(root, &["config", "user.email", "fixture@radroots.test"]); + git(root, &["config", "user.name", "Radroots Fixture"]); + git(root, &["remote", "add", "origin", remote]); + git(root, &["add", "."]); + git(root, &["commit", "--quiet", "-m", "fixture"]); + git(root, &["branch", "-M", "archive"]); + } + + fn create_bundle(root: &Path, output: &Path) { + let status = Command::new("git") + .args(["bundle", "create"]) + .arg(output) + .arg("refs/heads/archive") + .current_dir(root) + .status() + .expect("create source bundle"); + assert!(status.success()); + } + + fn sample_metadata() -> ReleaseMetadata { + ReleaseMetadata { + service: "fixture_service".to_owned(), + service_package: "fixture-service".to_owned(), + binary_name: "fixture-service".to_owned(), + version: "0.1.0-alpha".to_owned(), + } + } + + fn package( + id: &str, + name: &str, + source: Option<&str>, + checksum: Option<&str>, + license: Option<&str>, + binary: bool, + ) -> CargoPackage { + CargoPackage { + id: id.to_owned(), + name: name.to_owned(), + version: "0.1.0-alpha".to_owned(), + source: source.map(str::to_owned), + checksum: checksum.map(str::to_owned), + license: license.map(str::to_owned), + targets: if binary { + vec![CargoTarget { + name: name.to_owned(), + kind: vec!["bin".to_owned()], + }] + } else { + Vec::new() + }, + } + } + + #[test] + fn contract_matches_the_checked_in_decision() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root"); + validate_contract_inner(root).expect("release decision"); + } + + #[test] + fn exact_inventory_and_limits_are_literal() { + assert_eq!(INPUT_NAMES.len(), 8); + assert_eq!(OUTPUT_NAMES.len(), 18); + assert_eq!(MAX_TEXT_INPUT_BYTES, 1_048_576); + assert_eq!(MAX_GENERATED_DOCUMENT_BYTES, 16_777_216); + assert_eq!(MAX_SERVICE_CARGO_LOCK_BYTES, 16_777_216); + assert_eq!(MAX_SERVICE_FLAKE_LOCK_BYTES, 4_194_304); + assert_eq!(MAX_BINARY_BYTES, 536_870_912); + assert_eq!(MAX_SOURCE_BUNDLE_BYTES, 1_073_741_824); + assert_eq!(MAX_OCI_BYTES, 2_147_483_648); + assert_eq!(MAX_METADATA_BYTES, 33_554_432); + assert_eq!(MAX_PACKAGES, 8_192); + assert_eq!(MAX_WORKSPACE_PACKAGES, 64); + } + + #[test] + fn supply_chain_documents_are_deterministic_and_complete() { + let root_id = "path+file:///fixture#fixture-service@0.1.0-alpha"; + let dependency_id = "registry+https://example.invalid#index@0.1.0-alpha"; + let cargo = CargoMetadata { + packages: vec![ + package( + dependency_id, + "dependency", + Some("registry+https://github.com/rust-lang/crates.io-index"), + Some(&"a".repeat(64)), + Some("Apache-2.0"), + false, + ), + package(root_id, "fixture-service", None, None, Some("MIT"), true), + ], + workspace_members: vec![root_id.to_owned()], + resolve: Some(CargoResolve { + nodes: vec![ + CargoNode { + id: dependency_id.to_owned(), + dependencies: Vec::new(), + }, + CargoNode { + id: root_id.to_owned(), + dependencies: vec![dependency_id.to_owned()], + }, + ], + }), + }; + let (sbom, notices) = + build_supply_chain_documents(&sample_metadata(), cargo).expect("documents"); + let bytes = serde_json::to_vec(&sbom).expect("SBOM JSON"); + assert_eq!(serde_json::to_vec(&sbom).expect("SBOM JSON"), bytes); + assert_eq!(sbom.bom_format, "CycloneDX"); + assert_eq!(sbom.spec_version, "1.5"); + assert_eq!(sbom.metadata.component.name, "fixture-service"); + assert_eq!(sbom.components.len(), 1); + assert_eq!(sbom.dependencies.len(), 2); + assert!(notices.contains("Package: dependency 0.1.0-alpha")); + assert!(notices.contains("License: Apache-2.0")); + assert!(notices.contains("registry+https://github.com/rust-lang/crates.io-index")); + } + + #[test] + fn private_or_incomplete_dependency_evidence_is_rejected() { + let root_id = "root"; + for dependency in [ + package( + "dep", + "dep", + Some("git+ssh://git@github.com/private/repo"), + Some(&"a".repeat(64)), + Some("MIT"), + false, + ), + package( + "dep", + "dep", + Some("registry+https://github.com/rust-lang/crates.io-index"), + Some(&"a".repeat(64)), + None, + false, + ), + ] { + let cargo = CargoMetadata { + packages: vec![ + package(root_id, "fixture-service", None, None, Some("MIT"), true), + dependency, + ], + workspace_members: vec![root_id.to_owned()], + resolve: Some(CargoResolve { + nodes: vec![ + CargoNode { + id: root_id.to_owned(), + dependencies: vec!["dep".to_owned()], + }, + CargoNode { + id: "dep".to_owned(), + dependencies: Vec::new(), + }, + ], + }), + }; + assert!(matches!( + build_supply_chain_documents(&sample_metadata(), cargo), + Err(ReleaseArtifactError::InvalidPackageInventory) + )); + } + } + + #[test] + fn binary_archive_is_reproducible_and_metadata_is_fixed() { + let root = TempDir::new().expect("archive fixture"); + let source = root.path().join("service"); + let first = root.path().join("first.tar.gz"); + let second = root.path().join("second.tar.gz"); + write_file(&source, b"exact executable bytes"); + create_binary_archive(&source, &first, "fixture-service", 1_700_000_000) + .expect("first archive"); + create_binary_archive(&source, &second, "fixture-service", 1_700_000_000) + .expect("second archive"); + assert_eq!( + fs::read(&first).expect("first"), + fs::read(&second).expect("second") + ); + + let decoder = flate2::read::GzDecoder::new(fs::File::open(first).expect("archive")); + let mut archive = tar::Archive::new(decoder); + let mut entries = archive.entries().expect("entries"); + let mut entry = entries.next().expect("one entry").expect("entry"); + assert_eq!( + entry.path().expect("path").as_ref(), + Path::new("bin/fixture-service") + ); + assert_eq!(entry.header().mode().expect("mode"), 0o755); + assert_eq!(entry.header().mtime().expect("mtime"), 1_700_000_000); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).expect("archive bytes"); + assert_eq!(bytes, b"exact executable bytes"); + assert!(entries.next().is_none()); + } + + #[test] + fn full_artifact_set_is_reproducible_immutable_and_verifiable() { + let fixture = ReleaseFixture::new(); + fixture.write(&fixture.output_a).expect("first release"); + fixture.write(&fixture.output_b).expect("second release"); + fixture.check(&fixture.output_a).expect("check release"); + assert_eq!( + directory_inventory(&fixture.output_a, ReleaseArtifactError::StaleOutput) + .expect("first inventory"), + OUTPUT_NAMES.into_iter().map(str::to_owned).collect() + ); + for name in OUTPUT_NAMES { + assert_eq!( + fs::read(fixture.output_a.join(name)).expect("first output"), + fs::read(fixture.output_b.join(name)).expect("second output"), + "{name}" + ); + } + + let sums = + fs::read_to_string(fixture.output_a.join("SHA256SUMS")).expect("checksum inventory"); + let mut prior = ""; + let mut count = 0; + for line in sums.lines() { + let (digest, name) = line.split_once(" ").expect("checksum line"); + assert!(prior < name); + assert!(valid_lower_hex(digest, 64)); + assert_eq!( + digest, + hash_regular( + &fixture.output_a.join(name), + output_maximum(name).expect("known output") + ) + .expect("artifact hash") + .sha256 + ); + prior = name; + count += 1; + } + assert_eq!(count, OUTPUT_NAMES.len() - 1); + + for name in [ + "artifact-manifest.v1.json", + "oci-image.v1.json", + "provenance-input.v1.json", + "sbom.cdx.json", + "source-bundles.v1.json", + ] { + let bytes = fs::read(fixture.output_a.join(name)).expect("JSON output"); + assert_eq!(bytes.last(), Some(&b'\n')); + assert_eq!(bytes.iter().filter(|byte| **byte == b'\n').count(), 1); + serde_json::from_slice::<serde_json::Value>(&bytes).expect("valid JSON"); + } + let provenance: serde_json::Value = serde_json::from_slice( + &fs::read(fixture.output_a.join("provenance-input.v1.json")).expect("provenance"), + ) + .expect("provenance JSON"); + assert_eq!(provenance["signing_required"], true); + + write_file( + &fixture.output_a.join("config.example.toml"), + b"tampered = true\n", + ); + assert_eq!( + fixture.check(&fixture.output_a), + Err(ReleaseArtifactError::StaleOutput) + ); + assert_eq!( + fixture.write(&fixture.output_a), + Err(ReleaseArtifactError::StaleOutput) + ); + } + + #[test] + fn protected_text_and_invalid_inventory_fail_closed() { + let fixture = ReleaseFixture::new(); + write_file( + &fixture.input.join("config.example.toml"), + b"-----BEGIN PRIVATE KEY-----\n", + ); + assert_eq!( + fixture.write(&fixture.output_a), + Err(ReleaseArtifactError::ProtectedMaterialDetected) + ); + write_file( + &fixture.input.join("config.example.toml"), + b"enabled = true\n", + ); + write_file(&fixture.input.join("unexpected"), b"unexpected\n"); + assert_eq!( + fixture.write(&fixture.output_a), + Err(ReleaseArtifactError::InvalidInputRoot) + ); + } + + #[test] + fn source_lock_and_source_bundle_drift_fail_closed() { + let fixture = ReleaseFixture::new(); + write_file( + &fixture.service.join("Cargo.lock"), + b"version = 4\n# changed after source lock\n", + ); + git(&fixture.service, &["add", "Cargo.lock"]); + git( + &fixture.service, + &["commit", "--quiet", "-m", "change lock"], + ); + assert_eq!( + fixture.write(&fixture.output_a), + Err(ReleaseArtifactError::InvalidSourceLock) + ); + + let fixture = ReleaseFixture::new(); + let mut bundle = fs::read(fixture.input.join("lib-source.bundle")).expect("bundle"); + bundle[0] ^= 0xff; + write_file(&fixture.input.join("lib-source.bundle"), &bundle); + assert_eq!( + fixture.write(&fixture.output_a), + Err(ReleaseArtifactError::InvalidSourceBundle) + ); + } + + #[test] + fn output_name_is_one_bounded_component() { + assert!(valid_output_component("release-v1")); + for value in ["", ".", "..", "a/b", "/absolute", "a\\b"] { + assert!(!valid_output_component(value), "{value}"); + } + assert!(!valid_output_component(&"a".repeat(129))); + } + + #[test] + fn output_scope_and_target_admission_are_fail_closed() { + let fixture = ReleaseFixture::new(); + assert_eq!( + fixture.write(&fixture.service.join("release")), + Err(ReleaseArtifactError::InvalidOutputRoot) + ); + assert_eq!( + fixture.write(&fixture.input.join("release")), + Err(ReleaseArtifactError::InvalidOutputRoot) + ); + assert_eq!( + run_inner( + CommandMode::Write, + &fixture.service, + &fixture.input, + &fixture.output_a, + "x86_64-apple-darwin", + 1_700_000_000, + ), + Err(ReleaseArtifactError::InvalidServiceMetadata) + ); + assert_eq!( + run_inner( + CommandMode::Write, + &fixture.service, + &fixture.input, + &fixture.output_a, + "x86_64-unknown-linux-gnu", + 0, + ), + Err(ReleaseArtifactError::InvalidServiceMetadata) + ); + } + + #[test] + fn secret_scanner_detects_a_pattern_across_chunk_boundaries() { + let mut scanner = SecretScanner::default(); + scanner.scan(b"prefix -----BEGIN OPENSSH").expect("prefix"); + assert_eq!( + scanner.scan(b" PRIVATE KEY----- suffix"), + Err(ReleaseArtifactError::ProtectedMaterialDetected) + ); + } + + #[test] + fn identifiers_and_sources_are_closed() { + assert!(valid_snake_identifier("fixture_service")); + assert!(!valid_snake_identifier("fixture__service")); + assert!(valid_kebab_identifier("fixture-service")); + assert!(!valid_kebab_identifier("fixture--service")); + assert!(valid_public_source( + "registry+https://github.com/rust-lang/crates.io-index" + )); + assert!(valid_public_source( + "git+https://github.com/radrootslabs/lib?rev=1111111111111111111111111111111111111111#1111111111111111111111111111111111111111" + )); + assert!(!valid_public_source( + "git+ssh://git@github.com/private/repo" + )); + assert!(!valid_public_source("path+file:///secret")); + } + + #[test] + fn errors_are_stable_and_source_free() { + let errors = [ + ReleaseArtifactError::InvalidContract, + ReleaseArtifactError::InvalidServiceRoot, + ReleaseArtifactError::DirtyServiceSource, + ReleaseArtifactError::InvalidServiceMetadata, + ReleaseArtifactError::InvalidInputRoot, + ReleaseArtifactError::InvalidInputArtifact, + ReleaseArtifactError::InvalidSourceLock, + ReleaseArtifactError::InvalidSourceBundle, + ReleaseArtifactError::InvalidPackageInventory, + ReleaseArtifactError::ProtectedMaterialDetected, + ReleaseArtifactError::InvalidOutputRoot, + ReleaseArtifactError::StaleOutput, + ReleaseArtifactError::GenerationFailure, + ]; + for error in errors { + let display = error.to_string(); + let debug = format!("{error:?}"); + assert!(!display.contains('/')); + assert!(!display.contains("secret")); + assert!(!debug.contains('/')); + assert!(std::error::Error::source(&error).is_none()); + } + } +} diff --git a/tools/xtask/src/service_source_lock.rs b/tools/xtask/src/service_source_lock.rs @@ -134,6 +134,26 @@ impl ContractVersions { && self.status != 0 && self.provider != 0 } + + pub(crate) const fn config(self) -> u32 { + self.config + } + + pub(crate) const fn state(self) -> u32 { + self.state + } + + pub(crate) const fn admin(self) -> u32 { + self.admin + } + + pub(crate) const fn status(self) -> u32 { + self.status + } + + pub(crate) const fn provider(self) -> u32 { + self.provider + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -226,6 +246,34 @@ impl ServiceSourceLockV1 { debug_assert_eq!(self.raw.schema, LOCK_SCHEMA); &self.canonical } + + pub(crate) fn service(&self) -> &str { + &self.raw.service + } + + pub(crate) fn revision(&self) -> &str { + &self.raw.revision + } + + pub(crate) fn workspace_catalog_sha256(&self) -> &str { + &self.raw.workspace_catalog_sha256 + } + + pub(crate) fn source_archive_sha256(&self) -> &str { + &self.raw.source_archive_sha256 + } + + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.raw.cargo_lock_sha256 + } + + pub(crate) fn flake_lock_sha256(&self) -> &str { + &self.raw.flake_lock_sha256 + } + + pub(crate) const fn contract_versions(&self) -> ContractVersions { + self.raw.contract_versions + } } #[derive(Debug, Deserialize)]