commit 6d1e1ea218ad4d5a5213bedc88972f1d156bd6e0
parent b72c9fe073c3776294030003eb62b743c660927f
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 16:53:08 +0000
release: generate native artifacts
Diffstat:
14 files changed, 2063 insertions(+), 112 deletions(-)
diff --git a/.cargo/config.toml b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+xtask = "run --locked -p myc_xtask --"
diff --git a/AGENTS.md b/AGENTS.md
@@ -182,8 +182,18 @@
The executable must not provision deployment directory trees, derive runtime
limits from host CPUs, read secret arguments or environment variables, open
an existing live database before validating a restore manifest, publish from
- doctor, or return success for the deferred daemon `run` graph. Unit 15 alone
+ doctor, or return success without the Unit 15 daemon `run` graph. Unit 15
owns that graph, process signals, readiness/reconnect, and phase-aware drain.
+- Step 160 owns the standalone native release-artifact boundary in
+ `contracts/services_hardening/native_release.v2.json` and `cargo xtask
+ native-release`. Keep its exact two Linux targets, clean committed source,
+ caller-supplied binary, positive deterministic epoch, bounded generated
+ inventory, vendored offline source archive, source lock, CycloneDX SBOM,
+ notices, checksums, unsigned provenance, and fixed systemd template closed.
+ Outputs remain external to the source tree. Do not accept a caller service
+ root, arbitrary member name, Nix/NixOS/OCI input or output, signing key,
+ parent-owned human document, private harness, protected material, or
+ publication/deployment authority.
- Treat checked-in source, tests, and prototype behavior as implementation
evidence, not permission to preserve behavior that the active requirement
removes.
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"
@@ -433,6 +439,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"
@@ -604,12 +619,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"
@@ -1321,6 +1356,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.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1366,6 +1411,21 @@ dependencies = [
]
[[package]]
+name = "myc_xtask"
+version = "0.0.0"
+dependencies = [
+ "flate2",
+ "hex",
+ "rustix",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "toml",
+]
+
+[[package]]
name = "negentropy"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2277,6 +2337,12 @@ dependencies = [
]
[[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"
@@ -2484,6 +2550,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"
@@ -3240,6 +3317,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[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.2"
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 = "myc"
diff --git a/README b/README
@@ -34,15 +34,23 @@ let _publisher = MycStatusPublisher {};
let _snapshot = MycStatusSnapshot {};
```
-The native package and dependency-trust metadata is frozen by
-`contracts/services_hardening/native_release.v1.json`. Linux x86_64 and
-aarch64 are declared release targets, not qualified artifacts. The canonical
-service source lock binds the exact active public Lib cohort, Cargo lock,
-verified source archive, toolchain, feature profile, and service contract
-versions. Deferred flake source data is independently digest-bound and may
-select an older reachable Lib revision; it does not control native builds or
-claim Nix qualification. Nix, OCI, signing, tags, publication, and deployment
-remain deferred and unclaimed.
+The native package, artifact, and dependency-trust boundary is frozen by
+`contracts/services_hardening/native_release.v2.json`. Linux x86_64 and
+aarch64 are the only admitted native artifact targets. `cargo xtask
+native-release` consumes an exact prebuilt target binary from a clean Git head
+and deterministically writes or checks the complete binary/source archive,
+systemd, configuration, source-lock, SBOM, notices, checksum, manifest, and
+unsigned provenance inventory outside the source tree. The source archive
+vendors the exact locked Cargo graph and proves an offline metadata read before
+packaging. Signing credentials and parent-owned human documentation are never
+inputs.
+
+The canonical service source lock binds the exact active public Lib cohort,
+Cargo lock, verified Lib source archive, toolchain, feature profile, and
+service contract versions. Deferred flake source data is independently
+digest-bound and may select an older reachable Lib revision; it does not
+control native artifacts or claim Nix qualification. Nix, NixOS material, OCI,
+signing, tags, publication, and deployment remain deferred and unclaimed.
## Hardened v1 configuration contract
@@ -516,6 +524,14 @@ cargo extbuild doctor
cargo extbuild run -- ./scripts/release-acceptance.sh
```
+After building one of the two admitted Linux targets, create and then
+byte-check an external release directory with explicit deterministic evidence:
+
+```text
+cargo extbuild run -- cargo xtask native-release --mode write --target <aarch64-unknown-linux-gnu|x86_64-unknown-linux-gnu> --binary <absolute-binary> --output <absolute-new-directory> --source-date-epoch <positive-u32-seconds>
+cargo extbuild run -- cargo xtask native-release --mode check --target <same-target> --binary <same-absolute-binary> --output <same-absolute-directory> --source-date-epoch <same-seconds>
+```
+
Through RCLD-RSHR-170, Nix evaluation, builds, packages, NixOS modules, and
Nix-produced OCI artifacts are deferred and unclaimed. Do not install, repair,
invoke, or require Nix for these checkpoints. Run narrower ad hoc Cargo
diff --git a/contracts/services_hardening/native_release.v1.json b/contracts/services_hardening/native_release.v1.json
@@ -1,82 +0,0 @@
-{
- "schema": "radroots.myc.native-release",
- "schema_version": 1,
- "contract_version": 1,
- "service": "myc",
- "package": {
- "name": "myc",
- "binary": "myc",
- "version": "0.1.0",
- "repository": "https://github.com/radrootslabs/myc",
- "publish_to_crates_io": false
- },
- "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",
- "generator": "cargo xtask service-source-lock",
- "lib_repository": "https://github.com/radrootslabs/lib",
- "architecture": "radroots.crates.release.v2"
- },
- "contract_versions": {
- "config": 1,
- "state": 11,
- "admin": 1,
- "status": 1,
- "provider": 1
- },
- "native_targets": [
- {
- "target": "aarch64-unknown-linux-gnu",
- "posture": "target"
- },
- {
- "target": "x86_64-unknown-linux-gnu",
- "posture": "target"
- }
- ],
- "step_139_outputs": [
- "cargo_package_metadata",
- "release_profile",
- "dependency_source_trust",
- "service_source_lock"
- ],
- "deferred_to_step_160": [
- "native_binary_archive",
- "service_source_archive",
- "systemd_material",
- "sbom",
- "provenance",
- "notices",
- "checksums",
- "signing_inputs"
- ],
- "deferred_through_rcld_rshr_170": [
- "nix_evaluation",
- "nix_build",
- "nixos_module_qualification",
- "oci_artifact"
- ],
- "forbidden": [
- "local_or_path_lib_dependency",
- "floating_or_branch_lib_dependency",
- "mixed_lib_revision",
- "crates_io_publication",
- "signing",
- "tagging",
- "release_publication",
- "deployment"
- ]
-}
diff --git a/contracts/services_hardening/native_release.v2.json b/contracts/services_hardening/native_release.v2.json
@@ -0,0 +1,123 @@
+{
+ "schema": "radroots.myc.native-release",
+ "schema_version": 2,
+ "contract_version": 2,
+ "predecessor": {
+ "schema_version": 1,
+ "filename": "native_release.v1.json",
+ "transition": "forward_only_replace"
+ },
+ "service": "myc",
+ "package": {
+ "name": "myc",
+ "binary": "myc",
+ "version": "0.1.0",
+ "repository": "https://github.com/radrootslabs/myc",
+ "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"
+ },
+ "contract_versions": {
+ "config": 1,
+ "state": 11,
+ "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",
+ "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/myc@.service b/packaging/systemd/myc@.service
@@ -0,0 +1,37 @@
+[Unit]
+Description=Radroots Myc signer instance %i
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=myc
+Group=myc
+UMask=0077
+ExecStart=/usr/bin/myc --profile service-host --instance %i run
+Restart=on-failure
+RestartSec=5s
+ConfigDirectory=radroots/services/myc/%i
+ConfigDirectoryMode=0700
+StateDirectory=radroots/services/myc/%i
+StateDirectoryMode=0700
+CacheDirectory=radroots/services/myc/%i
+CacheDirectoryMode=0700
+LogsDirectory=radroots/services/myc/%i
+LogsDirectoryMode=0700
+RuntimeDirectory=radroots/services/myc/%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 = "8599fbc43a79dd13b0d496e86b7aefe2e6c863e016ac8a0d5612b7eb7cd979d6"
+cargo_lock_sha256 = "0283033e25df0697b43ade8bec97a72512aadcb3406beee0edc3b263d7b23ba6"
rust_version = "1.97.1"
host_feature_profile = "service-host"
diff --git a/scripts/release-acceptance.sh b/scripts/release-acceptance.sh
@@ -11,4 +11,5 @@ 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 myc_xtask
git diff --check
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -86,6 +86,7 @@ fn release_acceptance_checks_both_feature_profiles() {
assert!(RELEASE_ACCEPTANCE.contains(
"cargo check --locked --all-targets --no-default-features --features service-host\n"
));
+ assert!(RELEASE_ACCEPTANCE.contains("cargo test --locked -p myc_xtask\n"));
assert!(!RELEASE_ACCEPTANCE.contains("nix "));
}
diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs
@@ -4,11 +4,13 @@ use std::collections::BTreeSet;
use serde_json::json;
-const CONTRACT: &str = include_str!("../contracts/services_hardening/native_release.v1.json");
+const CONTRACT: &str = include_str!("../contracts/services_hardening/native_release.v2.json");
const MANIFEST: &str = include_str!("../Cargo.toml");
const LOCK: &str = include_str!("../Cargo.lock");
const FLAKE: &str = include_str!("../flake.nix");
const FLAKE_LOCK: &str = include_str!("../flake.lock");
+const CARGO_CONFIG: &str = include_str!("../.cargo/config.toml");
+const SYSTEMD_UNIT: &str = include_str!("../packaging/systemd/myc@.service");
const LIB_REVISION: &str = "7d7b454b4c9ed86569671993bd03ca868b676665";
const DEFERRED_NIX_LIB_REVISION: &str = "b44119fbac5985be8127ad1bf56d2950e6399427";
@@ -21,8 +23,13 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
contract,
json!({
"schema": "radroots.myc.native-release",
- "schema_version": 1,
- "contract_version": 1,
+ "schema_version": 2,
+ "contract_version": 2,
+ "predecessor": {
+ "schema_version": 1,
+ "filename": "native_release.v1.json",
+ "transition": "forward_only_replace"
+ },
"service": "myc",
"package": {
"name": "myc",
@@ -31,6 +38,21 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
"repository": "https://github.com/radrootslabs/myc",
"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",
@@ -47,7 +69,6 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
"source_lock": {
"filename": "radroots.service.source-lock.v2.toml",
"schema": "radroots.service.source-lock.v2",
- "generator": "cargo xtask service-source-lock",
"lib_repository": LIB_REPOSITORY,
"architecture": "radroots.crates.release.v2"
},
@@ -62,22 +83,39 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
{ "target": "aarch64-unknown-linux-gnu", "posture": "target" },
{ "target": "x86_64-unknown-linux-gnu", "posture": "target" }
],
- "step_139_outputs": [
- "cargo_package_metadata",
- "release_profile",
- "dependency_source_trust",
- "service_source_lock"
+ "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"
],
- "deferred_to_step_160": [
- "native_binary_archive",
- "service_source_archive",
- "systemd_material",
- "sbom",
- "provenance",
- "notices",
- "checksums",
- "signing_inputs"
+ "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",
+ "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",
@@ -85,6 +123,13 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
"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",
@@ -139,6 +184,21 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
panic = "unwind"
})
);
+ assert_eq!(
+ CARGO_CONFIG,
+ "[alias]\nxtask = \"run --locked -p myc_xtask --\"\n"
+ );
+ for required in [
+ "ExecStart=/usr/bin/myc --profile service-host --instance %i run",
+ "ConfigDirectory=radroots/services/myc/%i",
+ "StateDirectory=radroots/services/myc/%i",
+ "RuntimeDirectory=radroots/services/myc/%i",
+ "UMask=0077",
+ "NoNewPrivileges=yes",
+ "ProtectSystem=strict",
+ ] {
+ assert!(SYSTEMD_UNIT.contains(required), "missing `{required}`");
+ }
}
#[test]
@@ -223,11 +283,20 @@ fn every_radroots_dependency_is_exactly_source_locked() {
}
#[test]
-fn removed_and_deferred_release_surfaces_cannot_be_smuggled_into_step_139() {
+fn native_release_surfaces_remain_generated_outside_the_source_tree() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
assert!(!root.join("radroots.lib.source-lock.v1.toml").exists());
assert!(!root.join("radroots.service.source-lock.v1.toml").exists());
assert!(root.join("radroots.service.source-lock.v2.toml").is_file());
+ assert!(
+ !root
+ .join("contracts/services_hardening/native_release.v1.json")
+ .exists()
+ );
+ assert!(
+ root.join("contracts/services_hardening/native_release.v2.json")
+ .is_file()
+ );
for forbidden in [
".github",
"target",
@@ -245,4 +314,6 @@ fn removed_and_deferred_release_surfaces_cannot_be_smuggled_into_step_139() {
}
assert!(!CONTRACT.contains("qualified"));
assert!(!CONTRACT.contains("production_ready"));
+ assert!(!CONTRACT.contains("oci-image"));
+ assert!(!CONTRACT.contains("nixos-module"));
}
diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "myc_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,1662 @@
+#![forbid(unsafe_code)]
+
+use std::{
+ collections::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 = "myc";
+const VERSION: &str = "0.1.0";
+const REPOSITORY: &str = "https://github.com/radrootslabs/myc";
+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/myc@.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_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-",
+];
+
+#[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(".myc-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/myc-native-release/v2",
+ builder_id: "https://radroots.dev/builders/myc-native-release/v2",
+ 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 != "deferred"
+ || lock
+ .nix
+ .lib_revision
+ .as_deref()
+ .is_none_or(|revision| !lower_hex(revision, 40))
+ || lock
+ .nix
+ .flake_lock_sha256
+ .as_deref()
+ .is_none_or(|digest| !lower_hex(digest, 64))
+ || 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!("myc-{VERSION}-{target}/myc"),
+ &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!("myc-{VERSION}-source"));
+ fs::create_dir(&source).map_err(|_| ReleaseError::Generation)?;
+ extract_exact_head(root, &source, work.path())?;
+ 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);
+ }
+ 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 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 mut properties = vec![SbomProperty {
+ name: "radroots:cargo_package_id",
+ value: package.id.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.id,
+ name: package.name,
+ version: package.version,
+ licenses,
+ properties,
+ });
+ }
+ let mut dependencies = metadata
+ .resolve
+ .as_ref()
+ .ok_or(ReleaseError::InvalidMetadata)?
+ .nodes
+ .iter()
+ .map(|node| {
+ let mut depends_on = node.dependencies.clone();
+ depends_on.sort();
+ depends_on.dedup();
+ SbomDependency {
+ reference: node.id.clone(),
+ depends_on,
+ }
+ })
+ .collect::<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,
+ ))
+}
+
+#[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 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_PATTERNS
+ .iter()
+ .any(|pattern| contains_bytes(&combined, pattern))
+ {
+ return Err(ReleaseError::ProtectedMaterial);
+ }
+ 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 scan_bytes(bytes: &[u8]) -> Result<(), ReleaseError> {
+ let mut scanner = SecretScanner::default();
+ scanner.scan(bytes)
+}
+
+fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+ !needle.is_empty()
+ && haystack
+ .windows(needle.len())
+ .any(|window| window == needle)
+}
+
+#[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/myc".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/myc".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/myc".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
+ );
+ }
+
+ #[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("myc");
+ 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 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());
+ }
+ }
+}