commit 7c3ae187bee0d3e319bb7dd2530d6f5b25479923
parent c8e0a9b614a8bc6a725028ed8df5a0c5c0477fb9
Author: triesap <tyson@radroots.org>
Date: Mon, 24 Aug 2026 00:07:38 +0000
release: define RHI native package contract
- add a bounded deterministic native artifact generator and fixed systemd package surface
- bind source lock, release metadata, SBOM, provenance, checksums, and standalone acceptance
- keep Nix, OCI, signing, publication, and deployment deferred
Diffstat:
12 files changed, 2558 insertions(+), 1 deletion(-)
diff --git a/.cargo/config.toml b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+xtask = "run --locked -p rhi_xtask --"
diff --git a/AGENTS.md b/AGENTS.md
@@ -320,6 +320,18 @@
response, deadline, backlog, retention set, and in-memory collection.
- Treat generated files as generated. Update them through the owning command
and run the corresponding freshness check.
+- Keep the native release generator private to `tools/xtask`. It may read
+ only an exact clean committed capsule revision and must write only one
+ caller-selected external output directory using the closed inventory in
+ `contracts/services_hardening/native_release.v1.json`. It must retain
+ bounded streaming I/O, deterministic archives, exact mode and durability
+ checks, protected-material scanning, and stable path-free errors.
+- Standalone package material may include the fixed systemd instance unit,
+ config example/schema, native binary/source archives, SBOM, notices,
+ provenance input, source lock, checksums, and manifest. It must not include
+ parent-owned human docs, credentials, Nix or OCI inputs/outputs, private
+ harnesses, signatures, tags, publication, deployment, or generated artifacts
+ in the source tree.
## 10. Canonical verification
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,6 +3,12 @@
version = 4
[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -387,6 +393,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
+name = "crc32fast"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "crossbeam-queue"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -557,12 +572,32 @@ dependencies = [
]
[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1253,6 +1288,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1853,6 +1898,21 @@ dependencies = [
]
[[package]]
+name = "rhi_xtask"
+version = "0.0.0"
+dependencies = [
+ "flate2",
+ "hex",
+ "rustix",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "toml",
+]
+
+[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2018,6 +2078,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2225,6 +2291,17 @@ dependencies = [
]
[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2809,6 +2886,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -12,6 +12,8 @@ publish = false
[workspace]
resolver = "3"
+members = [".", "tools/xtask"]
+default-members = ["."]
[workspace.metadata.radroots.service_source_lock]
service = "rhi"
@@ -23,6 +25,12 @@ admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
+[workspace.metadata.radroots.service_release]
+service = "rhi"
+service_package = "rhi"
+binary_name = "rhi"
+version = "0.1.0"
+
[workspace.lints.rust]
unsafe_code = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
@@ -73,3 +81,10 @@ zeroize = { version = "1" }
[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["macros", "rt-multi-thread"] }
+
+[profile.release]
+lto = "thin"
+codegen-units = 1
+overflow-checks = true
+strip = "symbols"
+panic = "unwind"
diff --git a/README b/README
@@ -241,6 +241,37 @@ and digest, and the positive backup-size limit. RHI adds no SQLite dependency,
raw connection, background runtime, implicit deadline, or direct file-copy
authority.
+## Standalone native release contract
+
+The capsule owns a private `cargo xtask native-release` generator and the
+machine-readable
+[`native_release.v1.json`](contracts/services_hardening/native_release.v1.json)
+contract. From one exact clean committed revision, a caller-supplied positive
+deterministic epoch, and an executable ELF64 binary for one governed GNU/Linux
+target, the generator creates one external immutable artifact directory. It
+contains the binary archive, a vendored offline source archive, the non-secret
+configuration example and schema, the standalone systemd unit, exact source
+lock, license and notices, CycloneDX SBOM, unsigned provenance input, artifact
+manifest, and sorted SHA-256 checksums.
+
+Generation is bounded, deterministic, collision-only, mode checked, and
+durability ordered. It scans exact tracked source, the selected binary, copied
+package inputs, and generated documents for governed protected-value patterns,
+then records that protected material, Nix qualification, and OCI content are
+absent. The source-locked third-party vendor graph is represented separately
+by checksums, SBOM, and notices. The generator does not sign, tag, publish,
+deploy, or write artifacts into the source tree. The standalone
+`scripts/release-acceptance.sh` surface exercises the native Cargo contract;
+when invoked by the parent monorepo it is itself run through extbuild.
+SBOM component references are domain-separated hashes of framed Cargo
+name/version/source/checksum identity, so they neither disclose a checkout path
+nor vary when the same exact source is built from another directory.
+
+The checked-in systemd instance unit is package material only. Its presence
+does not claim that the current placeholder binary graph is production-ready,
+installed, enabled, or started. Complete service runtime behavior remains with
+the later RHI checkpoints, and promotion remains Step 182 ownership.
+
Validate the standalone crate through extbuild:
```text
diff --git a/contracts/services_hardening/native_release.v1.json b/contracts/services_hardening/native_release.v1.json
@@ -0,0 +1,121 @@
+{
+ "schema": "radroots.rhi.native-release",
+ "schema_version": 1,
+ "contract_version": 1,
+ "service": "rhi",
+ "package": {
+ "name": "rhi",
+ "binary": "rhi",
+ "version": "0.1.0",
+ "repository": "https://github.com/radrootslabs/rhi",
+ "publish_to_crates_io": false
+ },
+ "generator": {
+ "command": "cargo xtask native-release",
+ "modes": ["check", "write"],
+ "required_arguments": [
+ "mode",
+ "target",
+ "binary",
+ "output",
+ "source_date_epoch"
+ ],
+ "source_date_epoch_range": "1..=4294967295",
+ "clean_exact_head": true,
+ "target_binary_validation": "executable_elf64_little_endian_exact_machine",
+ "canonical_json": "compact_utf8_json_with_one_final_lf",
+ "deterministic_archives": true,
+ "output_directory_mode": "0755",
+ "output_file_mode": "0644",
+ "durability": "sync_files_then_output_directory_then_parent"
+ },
+ "toolchain": {
+ "rust_version": "1.97.1",
+ "edition": "2024",
+ "resolver": "3",
+ "host_feature_profile": "service-host"
+ },
+ "release_profile": {
+ "lto": "thin",
+ "codegen_units": 1,
+ "overflow_checks": true,
+ "strip": "symbols",
+ "panic": "unwind"
+ },
+ "source_lock": {
+ "filename": "radroots.service.source-lock.v2.toml",
+ "schema": "radroots.service.source-lock.v2",
+ "lib_repository": "https://github.com/radrootslabs/lib",
+ "architecture": "radroots.crates.release.v2",
+ "nix_material": "absent"
+ },
+ "contract_versions": {
+ "config": 1,
+ "state": 2,
+ "admin": 1,
+ "status": 1,
+ "provider": 1
+ },
+ "native_targets": [
+ { "target": "aarch64-unknown-linux-gnu", "posture": "target" },
+ { "target": "x86_64-unknown-linux-gnu", "posture": "target" }
+ ],
+ "output_inventory": [
+ "LICENSE",
+ "SHA256SUMS",
+ "THIRD-PARTY-NOTICES.txt",
+ "artifact-manifest.v1.json",
+ "binary.tar.gz",
+ "config.example.toml",
+ "config.schema.json",
+ "provenance-input.v1.json",
+ "radroots.service.source-lock.v2.toml",
+ "sbom.cdx.json",
+ "service-source.tar.gz",
+ "systemd.service"
+ ],
+ "signing_inputs": [
+ "SHA256SUMS",
+ "artifact-manifest.v1.json",
+ "provenance-input.v1.json"
+ ],
+ "provenance_posture": "deterministic_unsigned_slsa_v1_input_external_keys_only",
+ "sbom_format": "cyclonedx_json_1_5_locked_cargo_graph",
+ "sbom_component_identity": "domain_separated_sha256_of_framed_name_version_source_checksum",
+ "protected_material_scan": "tracked_source_binary_copied_and_generated_material_fixed_patterns",
+ "source_archive": "locked_offline_cargo_build_with_vendored_dependencies",
+ "checksum_format": "sha256_lower_hex_two_spaces_path_lf_sorted_by_path",
+ "protected_material_included": false,
+ "maximums": {
+ "text_input_bytes": 1048576,
+ "generated_document_bytes": 16777216,
+ "cargo_metadata_bytes": 33554432,
+ "binary_bytes": 536870912,
+ "source_archive_bytes": 1073741824,
+ "packages": 8192,
+ "tracked_files": 4096
+ },
+ "deferred_through_rcld_rshr_170": [
+ "nix_evaluation",
+ "nix_build",
+ "nixos_module_qualification",
+ "oci_artifact"
+ ],
+ "forbidden": [
+ "nix_input",
+ "nixos_module_output",
+ "oci_input",
+ "oci_output",
+ "protected_material",
+ "parent_owned_human_docs",
+ "private_harness",
+ "local_or_path_lib_dependency",
+ "floating_or_branch_lib_dependency",
+ "mixed_lib_revision",
+ "crates_io_publication",
+ "signing",
+ "tagging",
+ "release_publication",
+ "deployment"
+ ]
+}
diff --git a/packaging/systemd/rhi@.service b/packaging/systemd/rhi@.service
@@ -0,0 +1,37 @@
+[Unit]
+Description=Radroots RHI evidence service instance %i
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=rhi
+Group=rhi
+UMask=0077
+ExecStart=/usr/bin/rhi --profile service-host --instance %i run
+Restart=on-failure
+RestartSec=5s
+ConfigDirectory=radroots/services/rhi/%i
+ConfigDirectoryMode=0700
+StateDirectory=radroots/services/rhi/%i
+StateDirectoryMode=0700
+CacheDirectory=radroots/services/rhi/%i
+CacheDirectoryMode=0700
+LogsDirectory=radroots/services/rhi/%i
+LogsDirectoryMode=0700
+RuntimeDirectory=radroots/services/rhi/%i
+RuntimeDirectoryMode=0700
+RuntimeDirectoryPreserve=yes
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectHome=yes
+ProtectSystem=strict
+ProtectControlGroups=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+RestrictSUIDSGID=yes
+LockPersonality=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2"
workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
version = "0.1.0-alpha"
source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0"
-cargo_lock_sha256 = "f519a64d8093610f9536a0911ddac85412e2f6aef2724cb09b5af5b32ab9654b"
+cargo_lock_sha256 = "465af24eddf8593416eb67802dd74a29baac4df8086449c19464078fffafd0e2"
rust_version = "1.97.1"
host_feature_profile = "service-host"
diff --git a/scripts/release-acceptance.sh b/scripts/release-acceptance.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+cargo fmt --all --check
+cargo metadata --locked --format-version 1 --no-deps >/dev/null
+cargo check --locked --all-targets --no-default-features
+cargo check --locked --all-targets --no-default-features --features service-host
+cargo check --locked --all-targets
+cargo clippy --locked --all-targets -- -D warnings
+cargo test --locked
+cargo test --locked -p rhi_xtask
+git diff --check
diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs
@@ -0,0 +1,386 @@
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeSet;
+
+use serde_json::json;
+
+const CONTRACT: &str = include_str!("../contracts/services_hardening/native_release.v1.json");
+const MANIFEST: &str = include_str!("../Cargo.toml");
+const LOCK: &str = include_str!("../Cargo.lock");
+const SOURCE_LOCK: &str = include_str!("../radroots.service.source-lock.v2.toml");
+const CARGO_CONFIG: &str = include_str!("../.cargo/config.toml");
+const SYSTEMD_UNIT: &str = include_str!("../packaging/systemd/rhi@.service");
+const RELEASE_ACCEPTANCE: &str = include_str!("../scripts/release-acceptance.sh");
+const XTASK_MANIFEST: &str = include_str!("../tools/xtask/Cargo.toml");
+
+const LIB_REVISION: &str = "7d7b454b4c9ed86569671993bd03ca868b676665";
+const LIB_REPOSITORY: &str = "https://github.com/radrootslabs/lib";
+
+#[test]
+fn native_release_contract_and_manifest_metadata_are_exact() {
+ let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("release contract");
+ assert_eq!(
+ contract,
+ json!({
+ "schema": "radroots.rhi.native-release",
+ "schema_version": 1,
+ "contract_version": 1,
+ "service": "rhi",
+ "package": {
+ "name": "rhi",
+ "binary": "rhi",
+ "version": "0.1.0",
+ "repository": "https://github.com/radrootslabs/rhi",
+ "publish_to_crates_io": false
+ },
+ "generator": {
+ "command": "cargo xtask native-release",
+ "modes": ["check", "write"],
+ "required_arguments": [
+ "mode", "target", "binary", "output", "source_date_epoch"
+ ],
+ "source_date_epoch_range": "1..=4294967295",
+ "clean_exact_head": true,
+ "target_binary_validation": "executable_elf64_little_endian_exact_machine",
+ "canonical_json": "compact_utf8_json_with_one_final_lf",
+ "deterministic_archives": true,
+ "output_directory_mode": "0755",
+ "output_file_mode": "0644",
+ "durability": "sync_files_then_output_directory_then_parent"
+ },
+ "toolchain": {
+ "rust_version": "1.97.1",
+ "edition": "2024",
+ "resolver": "3",
+ "host_feature_profile": "service-host"
+ },
+ "release_profile": {
+ "lto": "thin",
+ "codegen_units": 1,
+ "overflow_checks": true,
+ "strip": "symbols",
+ "panic": "unwind"
+ },
+ "source_lock": {
+ "filename": "radroots.service.source-lock.v2.toml",
+ "schema": "radroots.service.source-lock.v2",
+ "lib_repository": LIB_REPOSITORY,
+ "architecture": "radroots.crates.release.v2",
+ "nix_material": "absent"
+ },
+ "contract_versions": {
+ "config": 1,
+ "state": 2,
+ "admin": 1,
+ "status": 1,
+ "provider": 1
+ },
+ "native_targets": [
+ { "target": "aarch64-unknown-linux-gnu", "posture": "target" },
+ { "target": "x86_64-unknown-linux-gnu", "posture": "target" }
+ ],
+ "output_inventory": [
+ "LICENSE",
+ "SHA256SUMS",
+ "THIRD-PARTY-NOTICES.txt",
+ "artifact-manifest.v1.json",
+ "binary.tar.gz",
+ "config.example.toml",
+ "config.schema.json",
+ "provenance-input.v1.json",
+ "radroots.service.source-lock.v2.toml",
+ "sbom.cdx.json",
+ "service-source.tar.gz",
+ "systemd.service"
+ ],
+ "signing_inputs": [
+ "SHA256SUMS",
+ "artifact-manifest.v1.json",
+ "provenance-input.v1.json"
+ ],
+ "provenance_posture": "deterministic_unsigned_slsa_v1_input_external_keys_only",
+ "sbom_format": "cyclonedx_json_1_5_locked_cargo_graph",
+ "sbom_component_identity": "domain_separated_sha256_of_framed_name_version_source_checksum",
+ "protected_material_scan": "tracked_source_binary_copied_and_generated_material_fixed_patterns",
+ "source_archive": "locked_offline_cargo_build_with_vendored_dependencies",
+ "checksum_format": "sha256_lower_hex_two_spaces_path_lf_sorted_by_path",
+ "protected_material_included": false,
+ "maximums": {
+ "text_input_bytes": 1048576,
+ "generated_document_bytes": 16777216,
+ "cargo_metadata_bytes": 33554432,
+ "binary_bytes": 536870912,
+ "source_archive_bytes": 1073741824,
+ "packages": 8192,
+ "tracked_files": 4096
+ },
+ "deferred_through_rcld_rshr_170": [
+ "nix_evaluation",
+ "nix_build",
+ "nixos_module_qualification",
+ "oci_artifact"
+ ],
+ "forbidden": [
+ "nix_input",
+ "nixos_module_output",
+ "oci_input",
+ "oci_output",
+ "protected_material",
+ "parent_owned_human_docs",
+ "private_harness",
+ "local_or_path_lib_dependency",
+ "floating_or_branch_lib_dependency",
+ "mixed_lib_revision",
+ "crates_io_publication",
+ "signing",
+ "tagging",
+ "release_publication",
+ "deployment"
+ ]
+ })
+ );
+
+ let manifest: toml::Value = toml::from_str(MANIFEST).expect("Cargo manifest");
+ let package = manifest["package"].as_table().expect("package");
+ assert_eq!(
+ package["repository"].as_str(),
+ Some("https://github.com/radrootslabs/rhi")
+ );
+ assert_eq!(package["readme"].as_str(), Some("README"));
+ assert_eq!(package["publish"].as_bool(), Some(false));
+ assert_eq!(
+ manifest["workspace"]["members"],
+ toml::Value::Array(vec![
+ toml::Value::String(".".to_owned()),
+ toml::Value::String("tools/xtask".to_owned())
+ ])
+ );
+ assert_eq!(
+ manifest["workspace"]["metadata"]["radroots"]["service_release"],
+ toml::Value::Table(toml::toml! {
+ service = "rhi"
+ service_package = "rhi"
+ binary_name = "rhi"
+ version = "0.1.0"
+ })
+ );
+ assert_eq!(
+ manifest["profile"]["release"],
+ toml::Value::Table(toml::toml! {
+ lto = "thin"
+ codegen-units = 1
+ overflow-checks = true
+ strip = "symbols"
+ panic = "unwind"
+ })
+ );
+ assert_eq!(
+ CARGO_CONFIG,
+ "[alias]\nxtask = \"run --locked -p rhi_xtask --\"\n"
+ );
+ for required in [
+ "ExecStart=/usr/bin/rhi --profile service-host --instance %i run",
+ "ConfigDirectory=radroots/services/rhi/%i",
+ "StateDirectory=radroots/services/rhi/%i",
+ "CacheDirectory=radroots/services/rhi/%i",
+ "LogsDirectory=radroots/services/rhi/%i",
+ "RuntimeDirectory=radroots/services/rhi/%i",
+ "UMask=0077",
+ "NoNewPrivileges=yes",
+ "ProtectSystem=strict",
+ ] {
+ assert!(SYSTEMD_UNIT.contains(required), "missing `{required}`");
+ }
+}
+
+#[test]
+fn every_radroots_dependency_is_exactly_source_locked() {
+ let manifest: toml::Value = toml::from_str(MANIFEST).expect("Cargo manifest");
+ let dependencies = manifest["dependencies"].as_table().expect("dependencies");
+ let radroots = dependencies
+ .iter()
+ .filter(|(name, _)| name.starts_with("radroots_"))
+ .collect::<Vec<_>>();
+ assert_eq!(radroots.len(), 11);
+ for (name, dependency) in radroots {
+ let dependency = dependency.as_table().expect("detailed dependency");
+ assert_eq!(
+ dependency.get("git").and_then(toml::Value::as_str),
+ Some(LIB_REPOSITORY),
+ "{name}"
+ );
+ assert_eq!(
+ dependency.get("rev").and_then(toml::Value::as_str),
+ Some(LIB_REVISION),
+ "{name}"
+ );
+ assert_eq!(
+ dependency.get("version").and_then(toml::Value::as_str),
+ Some("=0.1.0-alpha"),
+ "{name}"
+ );
+ for forbidden in ["path", "branch", "tag"] {
+ assert!(
+ !dependency.contains_key(forbidden),
+ "{name} contains `{forbidden}`"
+ );
+ }
+ }
+ assert!(!MANIFEST.contains("[patch."));
+
+ let sources = LOCK
+ .lines()
+ .filter_map(|line| line.strip_prefix("source = \"git+"))
+ .filter_map(|line| line.strip_suffix('"'))
+ .filter(|source| source.contains("radrootslabs/lib"))
+ .collect::<BTreeSet<_>>();
+ assert_eq!(sources.len(), 1);
+ let source = sources.into_iter().next().expect("Lib source");
+ assert!(source.contains(&format!("?rev={LIB_REVISION}#{LIB_REVISION}")));
+
+ assert!(SOURCE_LOCK.contains("\n[nix]\nmaterial = \"absent\"\n"));
+ assert!(!SOURCE_LOCK.contains("lib_revision ="));
+ assert!(!SOURCE_LOCK.contains("flake_lock_sha256 ="));
+}
+
+#[test]
+fn native_release_surfaces_remain_external_and_preterminal() {
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+ for required in [
+ ".cargo/config.toml",
+ "packaging/systemd/rhi@.service",
+ "radroots.service.source-lock.v2.toml",
+ "scripts/release-acceptance.sh",
+ "tools/xtask/Cargo.toml",
+ "tools/xtask/src/main.rs",
+ "contracts/services_hardening/native_release.v1.json",
+ ] {
+ assert!(root.join(required).is_file(), "missing `{required}`");
+ }
+ assert!(
+ !root
+ .join("contracts/services_hardening/native_release.v2.json")
+ .exists()
+ );
+ for forbidden in [
+ ".github",
+ ".act",
+ "flake.nix",
+ "flake.lock",
+ "target",
+ "result",
+ "artifacts",
+ "dist",
+ "sbom.cdx.json",
+ "provenance-input.v1.json",
+ "oci-image.tar.gz",
+ ] {
+ assert!(
+ !root.join(forbidden).exists(),
+ "forbidden generated or deferred surface `{forbidden}` exists"
+ );
+ }
+ assert!(!CONTRACT.contains("qualified"));
+ assert!(!CONTRACT.contains("production_ready"));
+ assert!(!CONTRACT.contains("oci-image"));
+ assert!(!CONTRACT.contains("nixos-module"));
+}
+
+#[test]
+fn release_acceptance_is_standalone_and_has_no_deferred_toolchain() {
+ for required in [
+ "cargo fmt --all --check",
+ "cargo metadata --locked --format-version 1 --no-deps",
+ "cargo check --locked --all-targets --no-default-features",
+ "cargo check --locked --all-targets --no-default-features --features service-host",
+ "cargo clippy --locked --all-targets -- -D warnings",
+ "cargo test --locked",
+ "cargo test --locked -p rhi_xtask",
+ "git diff --check",
+ ] {
+ assert!(
+ RELEASE_ACCEPTANCE.contains(required),
+ "release acceptance is missing `{required}`"
+ );
+ }
+ for forbidden in [
+ "nix ",
+ "nix-",
+ "docker ",
+ "podman ",
+ "cosign ",
+ "gh release",
+ ] {
+ assert!(
+ !RELEASE_ACCEPTANCE.contains(forbidden),
+ "release acceptance contains deferred or unauthorized command `{forbidden}`"
+ );
+ }
+}
+
+#[test]
+fn release_tool_dependency_and_file_mode_boundaries_are_exact() {
+ let manifest: toml::Value = toml::from_str(XTASK_MANIFEST).expect("xtask manifest");
+ assert_eq!(manifest["package"]["name"].as_str(), Some("rhi_xtask"));
+ assert_eq!(manifest["package"]["publish"].as_bool(), Some(false));
+ let dependencies = manifest["dependencies"]
+ .as_table()
+ .expect("xtask dependencies")
+ .keys()
+ .map(String::as_str)
+ .collect::<BTreeSet<_>>();
+ assert_eq!(
+ dependencies,
+ BTreeSet::from([
+ "flate2",
+ "hex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "toml",
+ ])
+ );
+ assert_eq!(
+ manifest["target"]["cfg(unix)"]["dependencies"]
+ .as_table()
+ .expect("Unix dependencies")
+ .keys()
+ .map(String::as_str)
+ .collect::<BTreeSet<_>>(),
+ BTreeSet::from(["rustix"])
+ );
+ for forbidden in [
+ "sqlx", "rusqlite", "reqwest", "tokio", "\nnix =", "nix::", "oci", "cosign", "path =",
+ "git =",
+ ] {
+ assert!(
+ !XTASK_MANIFEST.contains(forbidden),
+ "release tool contains unauthorized dependency or authority `{forbidden}`"
+ );
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+ assert_eq!(
+ std::fs::metadata(root.join("scripts/release-acceptance.sh"))
+ .expect("release script metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o755
+ );
+ assert_eq!(
+ std::fs::metadata(root.join("packaging/systemd/rhi@.service"))
+ .expect("systemd unit metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o644
+ );
+ }
+}
diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "rhi_xtask"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+flate2 = "1"
+hex = "0.4"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
+tar = "0.4"
+tempfile = "3.17"
+toml = "0.8"
+
+[target.'cfg(unix)'.dependencies]
+rustix = { version = "1", features = ["fs"] }
+
+[lints]
+workspace = true
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -0,0 +1,1830 @@
+#![forbid(unsafe_code)]
+
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ env, fmt, fs,
+ io::{Read as _, Write as _},
+ path::{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::{NamedTempFile, TempDir};
+
+const SERVICE: &str = "rhi";
+const VERSION: &str = "0.1.0";
+const REPOSITORY: &str = "https://github.com/radrootslabs/rhi";
+const RUST_VERSION: &str = "1.97.1";
+const HOST_FEATURE_PROFILE: &str = "service-host";
+const SOURCE_LOCK: &str = "radroots.service.source-lock.v2.toml";
+const CONFIG_EXAMPLE: &str = "contracts/services_hardening/config.v1.example.toml";
+const CONFIG_SCHEMA: &str = "contracts/services_hardening/config.v1.schema.json";
+const SYSTEMD_UNIT: &str = "packaging/systemd/rhi@.service";
+const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"];
+const OUTPUT_NAMES: [&str; 12] = [
+ "LICENSE",
+ "SHA256SUMS",
+ "THIRD-PARTY-NOTICES.txt",
+ "artifact-manifest.v1.json",
+ "binary.tar.gz",
+ "config.example.toml",
+ "config.schema.json",
+ "provenance-input.v1.json",
+ SOURCE_LOCK,
+ "sbom.cdx.json",
+ "service-source.tar.gz",
+ "systemd.service",
+];
+const MAX_TEXT_BYTES: u64 = 1_048_576;
+const MAX_DOCUMENT_BYTES: u64 = 16_777_216;
+const MAX_METADATA_BYTES: u64 = 33_554_432;
+const MAX_BINARY_BYTES: u64 = 536_870_912;
+const MAX_SOURCE_ARCHIVE_BYTES: u64 = 1_073_741_824;
+const MAX_TRACKED_FILES: usize = 4_096;
+const MAX_PACKAGES: usize = 8_192;
+const COPY_BUFFER_BYTES: usize = 65_536;
+const SECRET_PATTERN_PARTS: [(&[u8], &[u8]); 7] = [
+ (b"-----BEGIN PRIVATE ", b"KEY-----"),
+ (b"-----BEGIN RSA PRIVATE ", b"KEY-----"),
+ (b"-----BEGIN EC PRIVATE ", b"KEY-----"),
+ (b"-----BEGIN OPENSSH PRIVATE ", b"KEY-----"),
+ (b"github_", b"pat_"),
+ (b"gh", b"p_"),
+ (b"xo", b"xb-"),
+];
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum Mode {
+ Check,
+ Write,
+}
+
+#[derive(Debug)]
+struct NativeReleaseArgs {
+ mode: Mode,
+ target: String,
+ binary: PathBuf,
+ output: PathBuf,
+ source_date_epoch: u32,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ReleaseError {
+ InvalidArguments,
+ InvalidSource,
+ DirtySource,
+ InvalidBinary,
+ InvalidOutput,
+ InvalidMetadata,
+ InvalidSourceLock,
+ ProtectedMaterial,
+ StaleOutput,
+ Generation,
+}
+
+impl ReleaseError {
+ const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidArguments => "invalid_arguments",
+ Self::InvalidSource => "invalid_source",
+ Self::DirtySource => "dirty_source",
+ Self::InvalidBinary => "invalid_binary",
+ Self::InvalidOutput => "invalid_output",
+ Self::InvalidMetadata => "invalid_metadata",
+ Self::InvalidSourceLock => "invalid_source_lock",
+ Self::ProtectedMaterial => "protected_material_detected",
+ Self::StaleOutput => "stale_output",
+ Self::Generation => "generation_failure",
+ }
+ }
+}
+
+impl fmt::Display for ReleaseError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::InvalidArguments => "native release arguments are invalid",
+ Self::InvalidSource => "native release source is invalid",
+ Self::DirtySource => "native release source is not an exact clean revision",
+ Self::InvalidBinary => "native release binary is invalid",
+ Self::InvalidOutput => "native release output is invalid",
+ Self::InvalidMetadata => "native release metadata is invalid",
+ Self::InvalidSourceLock => "native release source lock is invalid",
+ Self::ProtectedMaterial => "native release input contains protected material",
+ Self::StaleOutput => "native release artifact set is absent or stale",
+ Self::Generation => "native release artifacts could not be generated",
+ })
+ }
+}
+
+impl std::error::Error for ReleaseError {}
+
+#[derive(Clone, Debug, Deserialize)]
+struct CargoMetadata {
+ packages: Vec<CargoPackage>,
+ workspace_members: Vec<String>,
+ resolve: Option<CargoResolve>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct CargoPackage {
+ id: String,
+ name: String,
+ version: String,
+ source: Option<String>,
+ checksum: Option<String>,
+ license: Option<String>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct CargoResolve {
+ nodes: Vec<CargoNode>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct CargoNode {
+ id: String,
+ dependencies: Vec<String>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SourceLock {
+ schema: String,
+ contract_version: u32,
+ service: String,
+ repository: String,
+ revision: String,
+ architecture: String,
+ workspace_catalog_sha256: String,
+ version: String,
+ source_archive_sha256: String,
+ cargo_lock_sha256: String,
+ rust_version: String,
+ host_feature_profile: String,
+ nix: NixEvidence,
+ contract_versions: ContractVersions,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct NixEvidence {
+ material: String,
+ lib_revision: Option<String>,
+ flake_lock_sha256: Option<String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct ContractVersions {
+ config: u32,
+ state: u32,
+ admin: u32,
+ status: u32,
+ provider: u32,
+}
+
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+struct ArtifactRecord {
+ path: String,
+ byte_length: u64,
+ sha256: String,
+}
+
+#[derive(Debug, Serialize)]
+struct ArtifactManifest {
+ schema: &'static str,
+ contract_version: u32,
+ service: &'static str,
+ version: &'static str,
+ target: String,
+ source_date_epoch: u32,
+ service_repository: &'static str,
+ service_revision: String,
+ lib_repository: String,
+ lib_revision: String,
+ rust_version: &'static str,
+ host_feature_profile: &'static str,
+ contract_versions: ContractVersions,
+ protected_material_included: bool,
+ nix_qualified: bool,
+ oci_included: bool,
+ artifacts: Vec<ArtifactRecord>,
+}
+
+#[derive(Debug, Serialize)]
+struct ProvenanceInput {
+ schema: &'static str,
+ contract_version: u32,
+ predicate_type: &'static str,
+ build_type: &'static str,
+ builder_id: &'static str,
+ service: &'static str,
+ version: &'static str,
+ target: String,
+ source_date_epoch: u32,
+ service_repository: &'static str,
+ service_revision: String,
+ lib_repository: String,
+ lib_revision: String,
+ source_lock_sha256: String,
+ manifest_sha256: String,
+ subjects: Vec<ArtifactRecord>,
+ signing_required: bool,
+ signed: bool,
+}
+
+#[derive(Debug, Serialize)]
+struct CycloneDxBom {
+ #[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(Debug, Serialize)]
+struct SbomMetadata {
+ component: SbomRootComponent,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomRootComponent {
+ #[serde(rename = "type")]
+ component_type: &'static str,
+ name: &'static str,
+ version: &'static str,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomComponent {
+ #[serde(rename = "type")]
+ component_type: &'static str,
+ #[serde(rename = "bom-ref")]
+ bom_ref: String,
+ name: String,
+ version: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ licenses: Option<Vec<SbomLicenseChoice>>,
+ properties: Vec<SbomProperty>,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomLicenseChoice {
+ expression: String,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomProperty {
+ name: &'static str,
+ value: String,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomDependency {
+ #[serde(rename = "ref")]
+ reference: String,
+ #[serde(rename = "dependsOn")]
+ depends_on: Vec<String>,
+}
+
+fn main() {
+ if let Err(error) = run_main() {
+ eprintln!("{}: {}", error.code(), error);
+ std::process::exit(1);
+ }
+}
+
+fn run_main() -> Result<(), ReleaseError> {
+ let mut arguments = env::args().skip(1);
+ match arguments.next().as_deref() {
+ Some("native-release") => {
+ let args = parse_native_release_args(arguments.collect())?;
+ native_release(&workspace_root(), &args)
+ }
+ _ => Err(ReleaseError::InvalidArguments),
+ }
+}
+
+fn workspace_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("xtask is nested at tools/xtask")
+ .to_path_buf()
+}
+
+fn parse_native_release_args(values: Vec<String>) -> Result<NativeReleaseArgs, ReleaseError> {
+ let mut mode = None;
+ let mut target = None;
+ let mut binary = None;
+ let mut output = None;
+ let mut source_date_epoch = None;
+ let mut index = 0;
+ while index < values.len() {
+ let value = values
+ .get(index + 1)
+ .ok_or(ReleaseError::InvalidArguments)?;
+ match values[index].as_str() {
+ "--mode" => {
+ let parsed = match value.as_str() {
+ "check" => Mode::Check,
+ "write" => Mode::Write,
+ _ => return Err(ReleaseError::InvalidArguments),
+ };
+ if mode.replace(parsed).is_some() {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ }
+ "--target" => {
+ if target.replace(value.clone()).is_some() {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ }
+ "--binary" => {
+ if binary.replace(PathBuf::from(value)).is_some() {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ }
+ "--output" => {
+ if output.replace(PathBuf::from(value)).is_some() {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ }
+ "--source-date-epoch" => {
+ let parsed = value
+ .parse::<u32>()
+ .ok()
+ .filter(|value| *value > 0)
+ .ok_or(ReleaseError::InvalidArguments)?;
+ if source_date_epoch.replace(parsed).is_some() {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ }
+ _ => return Err(ReleaseError::InvalidArguments),
+ }
+ index += 2;
+ }
+ let args = NativeReleaseArgs {
+ mode: mode.ok_or(ReleaseError::InvalidArguments)?,
+ target: target.ok_or(ReleaseError::InvalidArguments)?,
+ binary: binary.ok_or(ReleaseError::InvalidArguments)?,
+ output: output.ok_or(ReleaseError::InvalidArguments)?,
+ source_date_epoch: source_date_epoch.ok_or(ReleaseError::InvalidArguments)?,
+ };
+ if !SUPPORTED_TARGETS.contains(&args.target.as_str())
+ || !args.binary.is_absolute()
+ || !args.output.is_absolute()
+ {
+ return Err(ReleaseError::InvalidArguments);
+ }
+ Ok(args)
+}
+
+fn native_release(root: &Path, args: &NativeReleaseArgs) -> Result<(), ReleaseError> {
+ validate_source_root(root)?;
+ validate_clean_source(root)?;
+ let initial_head = git_capture(root, &["rev-parse", "HEAD"], 128)?;
+ let initial_head = exact_line(&initial_head).ok_or(ReleaseError::InvalidSource)?;
+ if !lower_hex(initial_head, 40) {
+ return Err(ReleaseError::InvalidSource);
+ }
+ validate_binary(&args.binary, &args.target)?;
+ validate_output_path(root, &args.output)?;
+ let source_lock = read_source_lock(root)?;
+ let metadata = cargo_metadata(root)?;
+ validate_metadata(&metadata)?;
+
+ let parent = args.output.parent().ok_or(ReleaseError::InvalidOutput)?;
+ let staging = tempfile::Builder::new()
+ .prefix(".rhi-native-release-")
+ .tempdir_in(parent)
+ .map_err(|_| ReleaseError::Generation)?;
+ let stage = staging.path();
+ set_directory_permissions(stage)?;
+
+ copy_bounded(
+ &root.join("LICENSE"),
+ &stage.join("LICENSE"),
+ MAX_TEXT_BYTES,
+ )?;
+ copy_bounded(
+ &root.join(CONFIG_EXAMPLE),
+ &stage.join("config.example.toml"),
+ MAX_TEXT_BYTES,
+ )?;
+ copy_bounded(
+ &root.join(CONFIG_SCHEMA),
+ &stage.join("config.schema.json"),
+ MAX_TEXT_BYTES,
+ )?;
+ copy_bounded(
+ &root.join(SYSTEMD_UNIT),
+ &stage.join("systemd.service"),
+ MAX_TEXT_BYTES,
+ )?;
+ copy_bounded(
+ &root.join(SOURCE_LOCK),
+ &stage.join(SOURCE_LOCK),
+ MAX_TEXT_BYTES,
+ )?;
+ create_binary_archive(
+ &args.binary,
+ &stage.join("binary.tar.gz"),
+ &args.target,
+ args.source_date_epoch,
+ )?;
+ create_source_archive(
+ root,
+ &stage.join("service-source.tar.gz"),
+ args.source_date_epoch,
+ )?;
+ let (sbom, notices) = supply_chain_documents(&metadata)?;
+ write_json(&stage.join("sbom.cdx.json"), &sbom)?;
+ write_generated(&stage.join("THIRD-PARTY-NOTICES.txt"), notices.as_bytes())?;
+
+ let source_lock_sha256 = hash_regular(&stage.join(SOURCE_LOCK), MAX_TEXT_BYTES)?.sha256;
+ let payload = inventory_records(stage)?;
+ let manifest = ArtifactManifest {
+ schema: "radroots.service.release-artifacts.v1",
+ contract_version: 1,
+ service: SERVICE,
+ version: VERSION,
+ target: args.target.clone(),
+ source_date_epoch: args.source_date_epoch,
+ service_repository: REPOSITORY,
+ service_revision: initial_head.to_owned(),
+ lib_repository: source_lock.repository.clone(),
+ lib_revision: source_lock.revision.clone(),
+ rust_version: RUST_VERSION,
+ host_feature_profile: HOST_FEATURE_PROFILE,
+ contract_versions: source_lock.contract_versions.clone(),
+ protected_material_included: false,
+ nix_qualified: false,
+ oci_included: false,
+ artifacts: payload,
+ };
+ write_json(&stage.join("artifact-manifest.v1.json"), &manifest)?;
+ let manifest_sha256 =
+ hash_regular(&stage.join("artifact-manifest.v1.json"), MAX_DOCUMENT_BYTES)?.sha256;
+ let provenance = ProvenanceInput {
+ schema: "radroots.service.provenance-input.v1",
+ contract_version: 1,
+ predicate_type: "https://slsa.dev/provenance/v1",
+ build_type: "https://radroots.dev/contracts/rhi-native-release/v1",
+ builder_id: "https://radroots.dev/builders/rhi-native-release/v1",
+ service: SERVICE,
+ version: VERSION,
+ target: args.target.clone(),
+ source_date_epoch: args.source_date_epoch,
+ service_repository: REPOSITORY,
+ service_revision: initial_head.to_owned(),
+ lib_repository: source_lock.repository,
+ lib_revision: source_lock.revision,
+ source_lock_sha256,
+ manifest_sha256,
+ subjects: inventory_records(stage)?,
+ signing_required: true,
+ signed: false,
+ };
+ write_json(&stage.join("provenance-input.v1.json"), &provenance)?;
+ write_checksums(stage)?;
+ validate_exact_inventory(stage)?;
+ let expected = inventory_records(stage)?;
+
+ validate_clean_source(root)?;
+ if exact_line(&git_capture(root, &["rev-parse", "HEAD"], 128)?) != Some(initial_head) {
+ return Err(ReleaseError::DirtySource);
+ }
+
+ if args.output.exists() {
+ compare_output(&args.output, &expected)?;
+ sync_directory(&args.output)?;
+ sync_directory(parent)?;
+ return Ok(());
+ }
+ if args.mode == Mode::Check {
+ return Err(ReleaseError::StaleOutput);
+ }
+ sync_directory(stage)?;
+ publish_directory(stage, &args.output)?;
+ sync_directory(parent)?;
+ compare_output(&args.output, &expected)
+}
+
+fn validate_source_root(root: &Path) -> Result<(), ReleaseError> {
+ if !root.is_absolute()
+ || fs::symlink_metadata(root)
+ .map(|metadata| metadata.file_type().is_symlink() || !metadata.is_dir())
+ .unwrap_or(true)
+ || root.join("docs").exists()
+ || root.join(".github").exists()
+ || root.join(".act").exists()
+ {
+ return Err(ReleaseError::InvalidSource);
+ }
+ for required in [
+ "Cargo.toml",
+ "Cargo.lock",
+ "LICENSE",
+ SOURCE_LOCK,
+ CONFIG_EXAMPLE,
+ CONFIG_SCHEMA,
+ SYSTEMD_UNIT,
+ ] {
+ validate_regular(
+ &root.join(required),
+ MAX_DOCUMENT_BYTES,
+ ReleaseError::InvalidSource,
+ )?;
+ }
+ Ok(())
+}
+
+fn validate_clean_source(root: &Path) -> Result<(), ReleaseError> {
+ for arguments in [
+ &["diff", "--quiet", "--"] as &[&str],
+ &["diff", "--cached", "--quiet", "--"],
+ ] {
+ let status = Command::new("git")
+ .args(arguments)
+ .current_dir(root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|_| ReleaseError::DirtySource)?;
+ if !status.success() {
+ return Err(ReleaseError::DirtySource);
+ }
+ }
+ let untracked = command_capture_bounded(
+ Command::new("git")
+ .args(["ls-files", "--others", "--exclude-standard", "-z"])
+ .current_dir(root),
+ 1,
+ ReleaseError::DirtySource,
+ )?;
+ if !untracked.is_empty() {
+ return Err(ReleaseError::DirtySource);
+ }
+ Ok(())
+}
+
+fn validate_binary(path: &Path, target: &str) -> Result<(), ReleaseError> {
+ open_binary(path, target).map(|_| ())
+}
+
+fn validate_output_path(root: &Path, output: &Path) -> Result<(), ReleaseError> {
+ let parent = output.parent().ok_or(ReleaseError::InvalidOutput)?;
+ let canonical_root = fs::canonicalize(root).map_err(|_| ReleaseError::InvalidSource)?;
+ let canonical_parent = fs::canonicalize(parent).map_err(|_| ReleaseError::InvalidOutput)?;
+ if output == Path::new("/")
+ || output.components().any(|component| {
+ matches!(
+ component,
+ std::path::Component::CurDir
+ | std::path::Component::ParentDir
+ | std::path::Component::Prefix(_)
+ )
+ })
+ || canonical_parent.starts_with(canonical_root)
+ || fs::symlink_metadata(parent)
+ .map(|metadata| metadata.file_type().is_symlink() || !metadata.is_dir())
+ .unwrap_or(true)
+ || output
+ .file_name()
+ .and_then(|value| value.to_str())
+ .is_none_or(|value| value.is_empty() || value == "." || value == "..")
+ {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ match fs::symlink_metadata(output) {
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ Ok(_) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(_) => return Err(ReleaseError::InvalidOutput),
+ }
+ Ok(())
+}
+
+fn read_source_lock(root: &Path) -> Result<SourceLock, ReleaseError> {
+ let bytes = read_bounded(
+ &root.join(SOURCE_LOCK),
+ MAX_TEXT_BYTES,
+ ReleaseError::InvalidSourceLock,
+ )?;
+ let text = std::str::from_utf8(&bytes).map_err(|_| ReleaseError::InvalidSourceLock)?;
+ let lock: SourceLock = toml::from_str(text).map_err(|_| ReleaseError::InvalidSourceLock)?;
+ let cargo_lock = hash_regular(&root.join("Cargo.lock"), MAX_DOCUMENT_BYTES)?;
+ let revisions = cargo_dependency_revisions(root)?;
+ if lock.schema != "radroots.service.source-lock.v2"
+ || lock.contract_version != 2
+ || lock.service != SERVICE
+ || lock.repository != "https://github.com/radrootslabs/lib"
+ || !lower_hex(&lock.revision, 40)
+ || lock.architecture != "radroots.crates.release.v2"
+ || !lower_hex(&lock.workspace_catalog_sha256, 64)
+ || lock.version != "0.1.0-alpha"
+ || !lower_hex(&lock.source_archive_sha256, 64)
+ || lock.cargo_lock_sha256 != cargo_lock.sha256
+ || lock.rust_version != RUST_VERSION
+ || lock.host_feature_profile != HOST_FEATURE_PROFILE
+ || lock.nix.material != "absent"
+ || lock.nix.lib_revision.is_some()
+ || lock.nix.flake_lock_sha256.is_some()
+ || revisions != BTreeSet::from([lock.revision.clone()])
+ || [
+ lock.contract_versions.config,
+ lock.contract_versions.state,
+ lock.contract_versions.admin,
+ lock.contract_versions.status,
+ lock.contract_versions.provider,
+ ]
+ .contains(&0)
+ {
+ return Err(ReleaseError::InvalidSourceLock);
+ }
+ Ok(lock)
+}
+
+fn cargo_dependency_revisions(root: &Path) -> Result<BTreeSet<String>, ReleaseError> {
+ let bytes = read_bounded(
+ &root.join("Cargo.toml"),
+ MAX_TEXT_BYTES,
+ ReleaseError::InvalidSourceLock,
+ )?;
+ let value: toml::Value =
+ toml::from_str(std::str::from_utf8(&bytes).map_err(|_| ReleaseError::InvalidSourceLock)?)
+ .map_err(|_| ReleaseError::InvalidSourceLock)?;
+ let dependencies = value
+ .get("dependencies")
+ .and_then(toml::Value::as_table)
+ .ok_or(ReleaseError::InvalidSourceLock)?;
+ let mut revisions = BTreeSet::new();
+ let mut count = 0_usize;
+ for (name, dependency) in dependencies {
+ if !name.starts_with("radroots_") {
+ continue;
+ }
+ count += 1;
+ let table = dependency
+ .as_table()
+ .ok_or(ReleaseError::InvalidSourceLock)?;
+ if table.get("git").and_then(toml::Value::as_str)
+ != Some("https://github.com/radrootslabs/lib")
+ || table.contains_key("path")
+ || table.contains_key("branch")
+ || table.contains_key("tag")
+ {
+ return Err(ReleaseError::InvalidSourceLock);
+ }
+ revisions.insert(
+ table
+ .get("rev")
+ .and_then(toml::Value::as_str)
+ .filter(|revision| lower_hex(revision, 40))
+ .ok_or(ReleaseError::InvalidSourceLock)?
+ .to_owned(),
+ );
+ }
+ if count != 11 {
+ return Err(ReleaseError::InvalidSourceLock);
+ }
+ Ok(revisions)
+}
+
+fn cargo_metadata(root: &Path) -> Result<CargoMetadata, ReleaseError> {
+ let bytes = command_capture_bounded(
+ Command::new("cargo")
+ .args(["metadata", "--format-version", "1", "--locked", "--offline"])
+ .current_dir(root),
+ MAX_METADATA_BYTES,
+ ReleaseError::InvalidMetadata,
+ )?;
+ serde_json::from_slice(&bytes).map_err(|_| ReleaseError::InvalidMetadata)
+}
+
+fn validate_metadata(metadata: &CargoMetadata) -> Result<(), ReleaseError> {
+ if metadata.packages.is_empty()
+ || metadata.packages.len() > MAX_PACKAGES
+ || metadata.workspace_members.len() != 2
+ || metadata.resolve.is_none()
+ || !metadata
+ .packages
+ .iter()
+ .any(|package| package.name == SERVICE && package.version == VERSION)
+ {
+ return Err(ReleaseError::InvalidMetadata);
+ }
+ Ok(())
+}
+
+fn create_binary_archive(
+ binary: &Path,
+ output: &Path,
+ target: &str,
+ epoch: u32,
+) -> Result<(), ReleaseError> {
+ let mut input = open_binary(binary, target)?;
+ let metadata = input.metadata().map_err(|_| ReleaseError::InvalidBinary)?;
+ let file = create_new(output)?;
+ let encoder = GzBuilder::new().mtime(epoch).write(
+ BoundedWriter::new(file, MAX_BINARY_BYTES + MAX_TEXT_BYTES),
+ Compression::best(),
+ );
+ let mut tar = TarBuilder::new(encoder);
+ tar.mode(tar::HeaderMode::Deterministic);
+ let mut header = TarHeader::new_gnu();
+ header.set_size(metadata.len());
+ header.set_mode(0o755);
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(u64::from(epoch));
+ header.set_cksum();
+ tar.append_data(
+ &mut header,
+ format!("rhi-{VERSION}-{target}/rhi"),
+ &mut input,
+ )
+ .map_err(|_| ReleaseError::Generation)?;
+ let encoder = tar.into_inner().map_err(|_| ReleaseError::Generation)?;
+ encoder
+ .finish()
+ .map_err(|_| ReleaseError::Generation)?
+ .sync_all()
+ .map_err(|_| ReleaseError::Generation)
+}
+
+fn create_source_archive(root: &Path, output: &Path, epoch: u32) -> Result<(), ReleaseError> {
+ let work = TempDir::new().map_err(|_| ReleaseError::Generation)?;
+ let source = work.path().join(format!("rhi-{VERSION}-source"));
+ fs::create_dir(&source).map_err(|_| ReleaseError::Generation)?;
+ extract_exact_head(root, &source, work.path())?;
+ scan_source_tree(&source)?;
+ let tracked = count_tree(&source)?;
+ if tracked == 0 || tracked > MAX_TRACKED_FILES {
+ return Err(ReleaseError::InvalidSource);
+ }
+ let vendor_config = command_capture_bounded(
+ Command::new("cargo")
+ .args(["vendor", "--locked", "--versioned-dirs", "vendor"])
+ .current_dir(&source),
+ MAX_TEXT_BYTES,
+ ReleaseError::Generation,
+ )?;
+ let cargo_config = source.join(".cargo/config.toml");
+ let mut config = read_bounded(&cargo_config, MAX_TEXT_BYTES, ReleaseError::Generation)?;
+ config.extend_from_slice(b"\n");
+ config.extend_from_slice(&vendor_config);
+ if config.len() as u64 > MAX_TEXT_BYTES {
+ return Err(ReleaseError::Generation);
+ }
+ fs::write(&cargo_config, &config).map_err(|_| ReleaseError::Generation)?;
+ let _ = command_capture_bounded(
+ Command::new("cargo")
+ .args(["metadata", "--format-version", "1", "--locked", "--offline"])
+ .current_dir(&source),
+ MAX_METADATA_BYTES,
+ ReleaseError::Generation,
+ )?;
+ create_tree_archive(&source, output, epoch)
+}
+
+fn extract_exact_head(root: &Path, destination: &Path, work: &Path) -> Result<(), ReleaseError> {
+ let archive = work.join("source-head.tar");
+ let archive_file = fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&archive)
+ .map_err(|_| ReleaseError::Generation)?;
+ let status = Command::new("git")
+ .args(["archive", "--format=tar", "HEAD"])
+ .current_dir(root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::from(archive_file))
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|_| ReleaseError::InvalidSource)?;
+ if !status.success() {
+ return Err(ReleaseError::InvalidSource);
+ }
+ validate_regular(
+ &archive,
+ MAX_SOURCE_ARCHIVE_BYTES,
+ ReleaseError::InvalidSource,
+ )?;
+ let file = fs::File::open(archive).map_err(|_| ReleaseError::InvalidSource)?;
+ tar::Archive::new(file)
+ .unpack(destination)
+ .map_err(|_| ReleaseError::InvalidSource)
+}
+
+fn count_tree(root: &Path) -> Result<usize, ReleaseError> {
+ let mut count = 0_usize;
+ let mut pending = vec![root.to_path_buf()];
+ while let Some(directory) = pending.pop() {
+ let entries = fs::read_dir(directory).map_err(|_| ReleaseError::InvalidSource)?;
+ for entry in entries {
+ let entry = entry.map_err(|_| ReleaseError::InvalidSource)?;
+ let kind = entry.file_type().map_err(|_| ReleaseError::InvalidSource)?;
+ if kind.is_symlink() || (!kind.is_file() && !kind.is_dir()) {
+ return Err(ReleaseError::InvalidSource);
+ }
+ if kind.is_dir() {
+ pending.push(entry.path());
+ } else {
+ count = count.checked_add(1).ok_or(ReleaseError::InvalidSource)?;
+ if count > MAX_TRACKED_FILES {
+ return Err(ReleaseError::InvalidSource);
+ }
+ }
+ }
+ }
+ Ok(count)
+}
+
+#[cfg(unix)]
+fn open_binary(path: &Path, target: &str) -> Result<fs::File, ReleaseError> {
+ use rustix::fs::{Mode as FileMode, OFlags};
+ use std::io::Seek as _;
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let descriptor = rustix::fs::open(
+ path,
+ OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK,
+ FileMode::empty(),
+ )
+ .map_err(|_| ReleaseError::InvalidBinary)?;
+ let mut file = fs::File::from(descriptor);
+ let metadata = file.metadata().map_err(|_| ReleaseError::InvalidBinary)?;
+ if !metadata.is_file()
+ || metadata.len() < 20
+ || metadata.len() > MAX_BINARY_BYTES
+ || metadata.permissions().mode() & 0o111 == 0
+ {
+ return Err(ReleaseError::InvalidBinary);
+ }
+ let mut header = [0_u8; 20];
+ file.read_exact(&mut header)
+ .map_err(|_| ReleaseError::InvalidBinary)?;
+ file.rewind().map_err(|_| ReleaseError::InvalidBinary)?;
+ let expected_machine = match target {
+ "x86_64-unknown-linux-gnu" => 62_u16,
+ "aarch64-unknown-linux-gnu" => 183_u16,
+ _ => return Err(ReleaseError::InvalidBinary),
+ };
+ if header[..4] != [0x7f, b'E', b'L', b'F']
+ || header[4] != 2
+ || header[5] != 1
+ || header[6] != 1
+ || ![0_u8, 3_u8].contains(&header[7])
+ || ![2_u16, 3_u16].contains(&u16::from_le_bytes([header[16], header[17]]))
+ || u16::from_le_bytes([header[18], header[19]]) != expected_machine
+ {
+ return Err(ReleaseError::InvalidBinary);
+ }
+ let mut scanner = SecretScanner::default();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; COPY_BUFFER_BYTES];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|_| ReleaseError::InvalidBinary)?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidBinary)?)
+ .ok_or(ReleaseError::InvalidBinary)?;
+ if total > MAX_BINARY_BYTES {
+ return Err(ReleaseError::InvalidBinary);
+ }
+ scanner.scan(&buffer[..read])?;
+ }
+ if total != metadata.len() {
+ return Err(ReleaseError::InvalidBinary);
+ }
+ file.rewind().map_err(|_| ReleaseError::InvalidBinary)?;
+ Ok(file)
+}
+
+#[cfg(not(unix))]
+fn open_binary(_path: &Path, _target: &str) -> Result<fs::File, ReleaseError> {
+ Err(ReleaseError::InvalidBinary)
+}
+
+fn create_tree_archive(source: &Path, output: &Path, epoch: u32) -> Result<(), ReleaseError> {
+ let file = create_new(output)?;
+ let encoder = GzBuilder::new().mtime(epoch).write(
+ BoundedWriter::new(file, MAX_SOURCE_ARCHIVE_BYTES),
+ Compression::best(),
+ );
+ let mut tar = TarBuilder::new(encoder);
+ tar.mode(tar::HeaderMode::Deterministic);
+ let root_name = source.file_name().ok_or(ReleaseError::Generation)?;
+ append_tree(&mut tar, source, Path::new(root_name), epoch)?;
+ let encoder = tar.into_inner().map_err(|_| ReleaseError::Generation)?;
+ encoder
+ .finish()
+ .map_err(|_| ReleaseError::Generation)?
+ .sync_all()
+ .map_err(|_| ReleaseError::Generation)
+}
+
+fn append_tree<W: std::io::Write>(
+ tar: &mut TarBuilder<W>,
+ source: &Path,
+ archive_path: &Path,
+ epoch: u32,
+) -> Result<(), ReleaseError> {
+ let mut entries = fs::read_dir(source)
+ .map_err(|_| ReleaseError::Generation)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| ReleaseError::Generation)?;
+ entries.sort_by_key(fs::DirEntry::file_name);
+ for entry in entries {
+ let file_type = entry.file_type().map_err(|_| ReleaseError::Generation)?;
+ let path = entry.path();
+ let member = archive_path.join(entry.file_name());
+ if file_type.is_symlink() {
+ return Err(ReleaseError::Generation);
+ }
+ if file_type.is_dir() {
+ append_tree(tar, &path, &member, epoch)?;
+ continue;
+ }
+ if !file_type.is_file() {
+ return Err(ReleaseError::Generation);
+ }
+ let metadata = entry.metadata().map_err(|_| ReleaseError::Generation)?;
+ let mut file = fs::File::open(path).map_err(|_| ReleaseError::Generation)?;
+ let mut header = TarHeader::new_gnu();
+ header.set_size(metadata.len());
+ header.set_mode(0o644);
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(u64::from(epoch));
+ header.set_cksum();
+ tar.append_data(&mut header, member, &mut file)
+ .map_err(|_| ReleaseError::Generation)?;
+ }
+ Ok(())
+}
+
+fn supply_chain_documents(
+ metadata: &CargoMetadata,
+) -> Result<(CycloneDxBom, String), ReleaseError> {
+ validate_metadata(metadata)?;
+ let workspace = metadata
+ .workspace_members
+ .iter()
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let mut packages = metadata.packages.clone();
+ packages.sort_by(|left, right| left.id.cmp(&right.id));
+ let package_references = packages
+ .iter()
+ .map(|package| {
+ let is_workspace = workspace.contains(&package.id);
+ if !is_workspace && package.source.is_none() {
+ return Err(ReleaseError::InvalidMetadata);
+ }
+ Ok((
+ package.id.clone(),
+ stable_package_reference(package, is_workspace)?,
+ ))
+ })
+ .collect::<Result<BTreeMap<_, _>, _>>()?;
+ if package_references.len() != packages.len()
+ || package_references.values().collect::<BTreeSet<_>>().len() != packages.len()
+ {
+ return Err(ReleaseError::InvalidMetadata);
+ }
+ let mut components = Vec::with_capacity(packages.len());
+ let mut notices = String::from(
+ "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",
+ );
+ for package in packages {
+ let package_reference = package_references
+ .get(&package.id)
+ .ok_or(ReleaseError::InvalidMetadata)?
+ .clone();
+ let mut properties = vec![SbomProperty {
+ name: "radroots:cargo_component_ref",
+ value: package_reference.clone(),
+ }];
+ if let Some(source) = package.source {
+ properties.push(SbomProperty {
+ name: "radroots:cargo_source",
+ value: source,
+ });
+ }
+ if let Some(checksum) = package.checksum {
+ properties.push(SbomProperty {
+ name: "radroots:cargo_checksum",
+ value: checksum,
+ });
+ }
+ properties.push(SbomProperty {
+ name: "radroots:workspace_member",
+ value: workspace.contains(&package.id).to_string(),
+ });
+ let licenses = package.license.as_ref().map(|license| {
+ vec![SbomLicenseChoice {
+ expression: license.clone(),
+ }]
+ });
+ use fmt::Write as _;
+ writeln!(
+ notices,
+ "{} {} — {}",
+ package.name,
+ package.version,
+ package.license.as_deref().unwrap_or("NOASSERTION")
+ )
+ .map_err(|_| ReleaseError::Generation)?;
+ components.push(SbomComponent {
+ component_type: "library",
+ bom_ref: package_reference,
+ name: package.name,
+ version: package.version,
+ licenses,
+ properties,
+ });
+ }
+ let mut dependencies = metadata
+ .resolve
+ .as_ref()
+ .ok_or(ReleaseError::InvalidMetadata)?
+ .nodes
+ .iter()
+ .map(|node| -> Result<SbomDependency, ReleaseError> {
+ let reference = package_references
+ .get(&node.id)
+ .ok_or(ReleaseError::InvalidMetadata)?
+ .clone();
+ let mut depends_on = node
+ .dependencies
+ .iter()
+ .map(|dependency| {
+ package_references
+ .get(dependency)
+ .cloned()
+ .ok_or(ReleaseError::InvalidMetadata)
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ depends_on.sort();
+ depends_on.dedup();
+ Ok(SbomDependency {
+ reference,
+ depends_on,
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ dependencies.sort_by(|left, right| left.reference.cmp(&right.reference));
+ Ok((
+ CycloneDxBom {
+ bom_format: "CycloneDX",
+ spec_version: "1.5",
+ version: 1,
+ metadata: SbomMetadata {
+ component: SbomRootComponent {
+ component_type: "application",
+ name: SERVICE,
+ version: VERSION,
+ },
+ },
+ components,
+ dependencies,
+ },
+ notices,
+ ))
+}
+
+fn stable_package_reference(
+ package: &CargoPackage,
+ is_workspace: bool,
+) -> Result<String, ReleaseError> {
+ let mut hasher = Sha256::new();
+ hasher.update(b"radroots.service.native_release.cargo_component.v1\0");
+ hash_framed(&mut hasher, package.name.as_bytes())?;
+ hash_framed(&mut hasher, package.version.as_bytes())?;
+ hash_framed(
+ &mut hasher,
+ if is_workspace {
+ b"workspace"
+ } else {
+ package
+ .source
+ .as_deref()
+ .ok_or(ReleaseError::InvalidMetadata)?
+ .as_bytes()
+ },
+ )?;
+ hash_framed(
+ &mut hasher,
+ package.checksum.as_deref().unwrap_or("").as_bytes(),
+ )?;
+ Ok(format!(
+ "urn:radroots:cargo-component:sha256:{}",
+ hex::encode(hasher.finalize())
+ ))
+}
+
+fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), ReleaseError> {
+ let length = u64::try_from(bytes.len()).map_err(|_| ReleaseError::InvalidMetadata)?;
+ hasher.update(length.to_be_bytes());
+ hasher.update(bytes);
+ Ok(())
+}
+
+#[cfg(test)]
+fn validate_relative(value: &str) -> Result<(), ReleaseError> {
+ let path = Path::new(value);
+ if value.is_empty()
+ || path.is_absolute()
+ || path.components().any(|component| {
+ matches!(
+ component,
+ std::path::Component::ParentDir
+ | std::path::Component::RootDir
+ | std::path::Component::Prefix(_)
+ )
+ })
+ {
+ return Err(ReleaseError::InvalidSource);
+ }
+ Ok(())
+}
+
+fn copy_bounded(source: &Path, output: &Path, maximum: u64) -> Result<(), ReleaseError> {
+ validate_regular(source, maximum, ReleaseError::InvalidSource)?;
+ let metadata = fs::metadata(source).map_err(|_| ReleaseError::InvalidSource)?;
+ let mut input = fs::File::open(source).map_err(|_| ReleaseError::InvalidSource)?;
+ let mut target = create_new(output)?;
+ let mut scanner = SecretScanner::default();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; COPY_BUFFER_BYTES];
+ loop {
+ let read = input
+ .read(&mut buffer)
+ .map_err(|_| ReleaseError::InvalidSource)?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidSource)?)
+ .ok_or(ReleaseError::InvalidSource)?;
+ if total > maximum {
+ return Err(ReleaseError::InvalidSource);
+ }
+ scanner.scan(&buffer[..read])?;
+ target
+ .write_all(&buffer[..read])
+ .map_err(|_| ReleaseError::Generation)?;
+ }
+ if total != metadata.len() {
+ return Err(ReleaseError::InvalidSource);
+ }
+ target.sync_all().map_err(|_| ReleaseError::Generation)
+}
+
+fn scan_source_tree(root: &Path) -> Result<(), ReleaseError> {
+ let mut pending = vec![root.to_path_buf()];
+ let mut file_count = 0_usize;
+ while let Some(directory) = pending.pop() {
+ let entries = fs::read_dir(directory).map_err(|_| ReleaseError::InvalidSource)?;
+ for entry in entries {
+ let entry = entry.map_err(|_| ReleaseError::InvalidSource)?;
+ let kind = entry.file_type().map_err(|_| ReleaseError::InvalidSource)?;
+ if kind.is_symlink() || (!kind.is_file() && !kind.is_dir()) {
+ return Err(ReleaseError::InvalidSource);
+ }
+ if kind.is_dir() {
+ pending.push(entry.path());
+ continue;
+ }
+ file_count = file_count
+ .checked_add(1)
+ .ok_or(ReleaseError::InvalidSource)?;
+ if file_count > MAX_TRACKED_FILES {
+ return Err(ReleaseError::InvalidSource);
+ }
+ let path = entry.path();
+ validate_regular(&path, MAX_DOCUMENT_BYTES, ReleaseError::InvalidSource)?;
+ let mut file = fs::File::open(path).map_err(|_| ReleaseError::InvalidSource)?;
+ let mut scanner = SecretScanner::default();
+ let mut buffer = [0_u8; COPY_BUFFER_BYTES];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|_| ReleaseError::InvalidSource)?;
+ if read == 0 {
+ break;
+ }
+ scanner.scan(&buffer[..read])?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn create_new(path: &Path) -> Result<fs::File, ReleaseError> {
+ let mut options = fs::OpenOptions::new();
+ options.create_new(true).write(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt as _;
+ options.mode(0o644);
+ }
+ let file = options.open(path).map_err(|_| ReleaseError::Generation)?;
+ set_file_permissions(&file)?;
+ Ok(file)
+}
+
+#[cfg(unix)]
+fn set_file_permissions(file: &fs::File) -> Result<(), ReleaseError> {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ file.set_permissions(fs::Permissions::from_mode(0o644))
+ .map_err(|_| ReleaseError::Generation)
+}
+
+#[cfg(not(unix))]
+fn set_file_permissions(_file: &fs::File) -> Result<(), ReleaseError> {
+ Ok(())
+}
+
+#[cfg(unix)]
+fn set_directory_permissions(path: &Path) -> Result<(), ReleaseError> {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ fs::set_permissions(path, fs::Permissions::from_mode(0o755))
+ .map_err(|_| ReleaseError::Generation)
+}
+
+#[cfg(not(unix))]
+fn set_directory_permissions(_path: &Path) -> Result<(), ReleaseError> {
+ Ok(())
+}
+
+#[cfg(unix)]
+fn sync_directory(path: &Path) -> Result<(), ReleaseError> {
+ fs::File::open(path)
+ .and_then(|directory| directory.sync_all())
+ .map_err(|_| ReleaseError::Generation)
+}
+
+#[cfg(not(unix))]
+fn sync_directory(_path: &Path) -> Result<(), ReleaseError> {
+ Ok(())
+}
+
+#[cfg(unix)]
+fn publish_directory(source: &Path, destination: &Path) -> Result<(), ReleaseError> {
+ use rustix::fs::{CWD, RenameFlags, renameat_with};
+
+ renameat_with(CWD, source, CWD, destination, RenameFlags::NOREPLACE)
+ .map_err(|_| ReleaseError::Generation)
+}
+
+#[cfg(not(unix))]
+fn publish_directory(_source: &Path, _destination: &Path) -> Result<(), ReleaseError> {
+ Err(ReleaseError::Generation)
+}
+
+struct BoundedWriter<W> {
+ inner: W,
+ written: u64,
+ maximum: u64,
+}
+
+impl<W> BoundedWriter<W> {
+ const fn new(inner: W, maximum: u64) -> Self {
+ Self {
+ inner,
+ written: 0,
+ maximum,
+ }
+ }
+}
+
+impl BoundedWriter<fs::File> {
+ fn sync_all(&self) -> std::io::Result<()> {
+ self.inner.sync_all()
+ }
+}
+
+impl<W: std::io::Write> std::io::Write for BoundedWriter<W> {
+ fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
+ let remaining = self.maximum.saturating_sub(self.written);
+ if remaining == 0 && !bytes.is_empty() {
+ return Err(std::io::Error::other("bounded output exceeded"));
+ }
+ let admitted = bytes
+ .len()
+ .min(usize::try_from(remaining).unwrap_or(usize::MAX));
+ let written = self.inner.write(&bytes[..admitted])?;
+ self.written = self
+ .written
+ .checked_add(u64::try_from(written).map_err(std::io::Error::other)?)
+ .ok_or_else(|| std::io::Error::other("bounded output exceeded"))?;
+ Ok(written)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.inner.flush()
+ }
+}
+
+fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ReleaseError> {
+ let mut bytes = serde_json::to_vec(value).map_err(|_| ReleaseError::Generation)?;
+ bytes.push(b'\n');
+ write_generated(path, &bytes)
+}
+
+fn write_generated(path: &Path, bytes: &[u8]) -> Result<(), ReleaseError> {
+ if bytes.is_empty() || bytes.len() as u64 > MAX_DOCUMENT_BYTES {
+ return Err(ReleaseError::Generation);
+ }
+ scan_bytes(bytes)?;
+ let mut file = create_new(path)?;
+ file.write_all(bytes)
+ .and_then(|()| file.sync_all())
+ .map_err(|_| ReleaseError::Generation)
+}
+
+fn write_checksums(root: &Path) -> Result<(), ReleaseError> {
+ let records = inventory_records(root)?;
+ let mut output = String::new();
+ use fmt::Write as _;
+ for record in records {
+ writeln!(output, "{} {}", record.sha256, record.path)
+ .map_err(|_| ReleaseError::Generation)?;
+ }
+ write_generated(&root.join("SHA256SUMS"), output.as_bytes())
+}
+
+fn inventory_records(root: &Path) -> Result<Vec<ArtifactRecord>, ReleaseError> {
+ let mut names = fs::read_dir(root)
+ .map_err(|_| ReleaseError::InvalidOutput)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| ReleaseError::InvalidOutput)?;
+ names.sort_by_key(fs::DirEntry::file_name);
+ names
+ .into_iter()
+ .map(|entry| {
+ let name = entry
+ .file_name()
+ .into_string()
+ .map_err(|_| ReleaseError::InvalidOutput)?;
+ let evidence = hash_regular(&entry.path(), output_maximum(&name)?)?;
+ Ok(ArtifactRecord {
+ path: name,
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256,
+ })
+ })
+ .collect()
+}
+
+fn output_maximum(name: &str) -> Result<u64, ReleaseError> {
+ match name {
+ "binary.tar.gz" => Ok(MAX_BINARY_BYTES + MAX_TEXT_BYTES),
+ "service-source.tar.gz" => Ok(MAX_SOURCE_ARCHIVE_BYTES),
+ "LICENSE"
+ | "config.example.toml"
+ | "config.schema.json"
+ | "systemd.service"
+ | SOURCE_LOCK => Ok(MAX_TEXT_BYTES),
+ "SHA256SUMS"
+ | "THIRD-PARTY-NOTICES.txt"
+ | "artifact-manifest.v1.json"
+ | "provenance-input.v1.json"
+ | "sbom.cdx.json" => Ok(MAX_DOCUMENT_BYTES),
+ _ => Err(ReleaseError::InvalidOutput),
+ }
+}
+
+fn validate_exact_inventory(root: &Path) -> Result<(), ReleaseError> {
+ let actual = inventory_records(root)?;
+ if actual
+ .iter()
+ .map(|record| record.path.as_str())
+ .collect::<Vec<_>>()
+ != OUTPUT_NAMES
+ {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ validate_output_permissions(root)?;
+ Ok(())
+}
+
+#[cfg(unix)]
+fn validate_output_permissions(root: &Path) -> Result<(), ReleaseError> {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let root_metadata = fs::symlink_metadata(root).map_err(|_| ReleaseError::InvalidOutput)?;
+ if root_metadata.file_type().is_symlink()
+ || !root_metadata.is_dir()
+ || root_metadata.permissions().mode() & 0o777 != 0o755
+ {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ for name in OUTPUT_NAMES {
+ let metadata =
+ fs::symlink_metadata(root.join(name)).map_err(|_| ReleaseError::InvalidOutput)?;
+ if metadata.file_type().is_symlink()
+ || !metadata.is_file()
+ || metadata.permissions().mode() & 0o777 != 0o644
+ {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ }
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn validate_output_permissions(_root: &Path) -> Result<(), ReleaseError> {
+ Ok(())
+}
+
+fn compare_output(output: &Path, expected: &[ArtifactRecord]) -> Result<(), ReleaseError> {
+ validate_exact_inventory(output)?;
+ let actual = inventory_records(output)?;
+ if actual != expected {
+ return Err(ReleaseError::StaleOutput);
+ }
+ Ok(())
+}
+
+struct FileEvidence {
+ byte_length: u64,
+ sha256: String,
+}
+
+fn hash_regular(path: &Path, maximum: u64) -> Result<FileEvidence, ReleaseError> {
+ validate_regular(path, maximum, ReleaseError::InvalidOutput)?;
+ let metadata = fs::metadata(path).map_err(|_| ReleaseError::InvalidOutput)?;
+ let mut file = fs::File::open(path).map_err(|_| ReleaseError::InvalidOutput)?;
+ let mut hasher = Sha256::new();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; COPY_BUFFER_BYTES];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|_| ReleaseError::InvalidOutput)?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(u64::try_from(read).map_err(|_| ReleaseError::InvalidOutput)?)
+ .ok_or(ReleaseError::InvalidOutput)?;
+ if total > maximum {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ hasher.update(&buffer[..read]);
+ }
+ if total != metadata.len() {
+ return Err(ReleaseError::InvalidOutput);
+ }
+ Ok(FileEvidence {
+ byte_length: total,
+ sha256: hex::encode(hasher.finalize()),
+ })
+}
+
+fn validate_regular(path: &Path, maximum: u64, error: ReleaseError) -> Result<(), ReleaseError> {
+ let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
+ if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum {
+ return Err(error);
+ }
+ Ok(())
+}
+
+fn read_bounded(path: &Path, maximum: u64, error: ReleaseError) -> Result<Vec<u8>, ReleaseError> {
+ validate_regular(path, maximum, error)?;
+ let mut bytes = Vec::new();
+ 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 {
+ return Err(error);
+ }
+ Ok(bytes)
+}
+
+fn command_capture_bounded(
+ command: &mut Command,
+ maximum: u64,
+ error: ReleaseError,
+) -> Result<Vec<u8>, ReleaseError> {
+ let file = NamedTempFile::new().map_err(|_| error)?;
+ let stdout = file.reopen().map_err(|_| error)?;
+ let status = command
+ .stdin(Stdio::null())
+ .stdout(Stdio::from(stdout))
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|_| error)?;
+ if !status.success() {
+ return Err(error);
+ }
+ read_bounded(file.path(), maximum, error)
+}
+
+fn git_capture(root: &Path, arguments: &[&str], maximum: usize) -> Result<Vec<u8>, ReleaseError> {
+ command_capture_bounded(
+ Command::new("git").args(arguments).current_dir(root),
+ maximum as u64,
+ ReleaseError::InvalidSource,
+ )
+}
+
+fn exact_line(bytes: &[u8]) -> Option<&str> {
+ let value = std::str::from_utf8(bytes).ok()?.strip_suffix('\n')?;
+ (!value.is_empty() && !value.contains(['\n', '\r'])).then_some(value)
+}
+
+fn lower_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+#[derive(Default)]
+struct SecretScanner {
+ tail: Vec<u8>,
+}
+
+impl SecretScanner {
+ fn scan(&mut self, bytes: &[u8]) -> Result<(), ReleaseError> {
+ let mut combined = Vec::with_capacity(self.tail.len() + bytes.len());
+ combined.extend_from_slice(&self.tail);
+ combined.extend_from_slice(bytes);
+ if SECRET_PATTERN_PARTS
+ .iter()
+ .any(|(first, second)| contains_joined_bytes(&combined, first, second))
+ {
+ return Err(ReleaseError::ProtectedMaterial);
+ }
+ let retained = SECRET_PATTERN_PARTS
+ .iter()
+ .map(|(first, second)| first.len().saturating_add(second.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 scan_bytes(bytes: &[u8]) -> Result<(), ReleaseError> {
+ let mut scanner = SecretScanner::default();
+ scanner.scan(bytes)
+}
+
+fn contains_joined_bytes(haystack: &[u8], first: &[u8], second: &[u8]) -> bool {
+ let length = first.len().saturating_add(second.len());
+ length > 0
+ && haystack.windows(length).any(|window| {
+ window.get(..first.len()) == Some(first) && window.get(first.len()..) == Some(second)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn argument_parser_is_closed_and_bounded() {
+ let args = parse_native_release_args(vec![
+ "--mode".into(),
+ "check".into(),
+ "--target".into(),
+ "x86_64-unknown-linux-gnu".into(),
+ "--binary".into(),
+ "/tmp/rhi".into(),
+ "--output".into(),
+ "/tmp/release".into(),
+ "--source-date-epoch".into(),
+ "1".into(),
+ ])
+ .expect("valid arguments");
+ assert_eq!(args.mode, Mode::Check);
+ assert_eq!(args.source_date_epoch, 1);
+ for mutation in [
+ vec!["--mode".into(), "write".into()],
+ vec![
+ "--mode".into(),
+ "write".into(),
+ "--mode".into(),
+ "check".into(),
+ "--target".into(),
+ "x86_64-unknown-linux-gnu".into(),
+ "--binary".into(),
+ "/tmp/rhi".into(),
+ "--output".into(),
+ "/tmp/release".into(),
+ "--source-date-epoch".into(),
+ "1".into(),
+ ],
+ vec![
+ "--mode".into(),
+ "write".into(),
+ "--target".into(),
+ "x86_64-apple-darwin".into(),
+ "--binary".into(),
+ "/tmp/rhi".into(),
+ "--output".into(),
+ "/tmp/release".into(),
+ "--source-date-epoch".into(),
+ "1".into(),
+ ],
+ ] {
+ assert_eq!(
+ parse_native_release_args(mutation).expect_err("invalid arguments"),
+ ReleaseError::InvalidArguments
+ );
+ }
+ }
+
+ #[test]
+ fn secret_scanner_detects_split_patterns() {
+ let mut scanner = SecretScanner::default();
+ scanner.scan(b"prefix github_").expect("prefix");
+ assert_eq!(
+ scanner.scan(b"pat_value").expect_err("secret rejected"),
+ ReleaseError::ProtectedMaterial
+ );
+ }
+
+ #[test]
+ fn source_scanner_bounds_files_and_detects_protected_material() {
+ let directory = TempDir::new().expect("tempdir");
+ fs::write(directory.path().join("safe.rs"), b"fn safe() {}").expect("safe source");
+ scan_source_tree(directory.path()).expect("safe source tree");
+ fs::write(
+ directory.path().join("protected.txt"),
+ [b"github_".as_slice(), b"pat_value".as_slice()].concat(),
+ )
+ .expect("protected fixture");
+ assert_eq!(
+ scan_source_tree(directory.path()).expect_err("protected source rejected"),
+ ReleaseError::ProtectedMaterial
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn binary_archive_is_deterministic_and_contains_one_member() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = TempDir::new().expect("tempdir");
+ let binary = directory.path().join("rhi");
+ let mut elf = [0_u8; 20];
+ elf[..8].copy_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]);
+ elf[16..18].copy_from_slice(&3_u16.to_le_bytes());
+ elf[18..20].copy_from_slice(&62_u16.to_le_bytes());
+ fs::write(&binary, elf).expect("binary");
+ fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).expect("binary mode");
+ let first = directory.path().join("first.tar.gz");
+ let second = directory.path().join("second.tar.gz");
+ create_binary_archive(&binary, &first, "x86_64-unknown-linux-gnu", 1)
+ .expect("first archive");
+ create_binary_archive(&binary, &second, "x86_64-unknown-linux-gnu", 1)
+ .expect("second archive");
+ assert_eq!(
+ fs::read(first).expect("first"),
+ fs::read(second).expect("second")
+ );
+ assert_eq!(
+ create_binary_archive(
+ &binary,
+ &directory.path().join("wrong-target.tar.gz"),
+ "aarch64-unknown-linux-gnu",
+ 1,
+ )
+ .expect_err("target mismatch"),
+ ReleaseError::InvalidBinary
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn generated_permissions_are_exact() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let parent = TempDir::new().expect("tempdir");
+ let directory = parent.path().join("release");
+ fs::create_dir(&directory).expect("directory");
+ set_directory_permissions(&directory).expect("directory mode");
+ let file = directory.join("artifact");
+ create_new(&file).expect("artifact");
+ assert_eq!(
+ fs::metadata(directory)
+ .expect("directory metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o755
+ );
+ assert_eq!(
+ fs::metadata(file)
+ .expect("file metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o644
+ );
+ }
+
+ #[test]
+ fn compressed_outputs_are_bounded_before_allocation() {
+ let mut writer = BoundedWriter::new(Vec::new(), 3);
+ assert!(writer.write_all(b"abc").is_ok());
+ assert_eq!(writer.written, 3);
+ assert!(writer.write_all(b"d").is_err());
+ }
+
+ #[test]
+ fn sbom_uses_spdx_expressions_in_the_governed_field() {
+ assert_eq!(
+ serde_json::to_value(SbomLicenseChoice {
+ expression: "MIT OR Apache-2.0".to_owned(),
+ })
+ .expect("license choice"),
+ serde_json::json!({"expression": "MIT OR Apache-2.0"})
+ );
+ }
+
+ #[test]
+ fn sbom_component_references_are_path_free_and_checkout_independent() {
+ let package_at_first_path = CargoPackage {
+ id: "path+file:///private/first/rhi#0.1.0".to_owned(),
+ name: "rhi".to_owned(),
+ version: "0.1.0".to_owned(),
+ source: None,
+ checksum: None,
+ license: Some("AGPL-3.0-or-later".to_owned()),
+ };
+ let package_at_second_path = CargoPackage {
+ id: "path+file:///different/checkout/rhi#0.1.0".to_owned(),
+ ..package_at_first_path.clone()
+ };
+ let first =
+ stable_package_reference(&package_at_first_path, true).expect("first reference");
+ let second =
+ stable_package_reference(&package_at_second_path, true).expect("second reference");
+ assert_eq!(first, second);
+ assert!(first.starts_with("urn:radroots:cargo-component:sha256:"));
+ assert!(!first.contains("private"));
+ assert!(!first.contains("checkout"));
+ }
+
+ #[test]
+ fn relative_paths_reject_escape_and_absolute_values() {
+ for rejected in ["", "../escape", "a/../../escape", "/absolute"] {
+ assert_eq!(
+ validate_relative(rejected).expect_err("path rejected"),
+ ReleaseError::InvalidSource
+ );
+ }
+ validate_relative("contracts/config.json").expect("safe path");
+ }
+
+ #[test]
+ fn error_surface_is_fixed_and_source_free() {
+ for error in [
+ ReleaseError::InvalidArguments,
+ ReleaseError::InvalidSource,
+ ReleaseError::DirtySource,
+ ReleaseError::InvalidBinary,
+ ReleaseError::InvalidOutput,
+ ReleaseError::InvalidMetadata,
+ ReleaseError::InvalidSourceLock,
+ ReleaseError::ProtectedMaterial,
+ ReleaseError::StaleOutput,
+ ReleaseError::Generation,
+ ] {
+ assert!(!error.code().is_empty());
+ let display = error.to_string();
+ assert!(!display.contains('/'));
+ assert!(!display.contains("github_pat"));
+ assert!(std::error::Error::source(&error).is_none());
+ }
+ }
+}