myc

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

commit 6d1e1ea218ad4d5a5213bedc88972f1d156bd6e0
parent b72c9fe073c3776294030003eb62b743c660927f
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 16:53:08 +0000

release: generate native artifacts

Diffstat:
A.cargo/config.toml | 2++
MAGENTS.md | 12+++++++++++-
MCargo.lock | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 2++
MREADME | 34+++++++++++++++++++++++++---------
Dcontracts/services_hardening/native_release.v1.json | 82-------------------------------------------------------------------------------
Acontracts/services_hardening/native_release.v2.json | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackaging/systemd/myc@.service | 37+++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Mscripts/release-acceptance.sh | 1+
Mtests/build_policy.rs | 1+
Mtests/services_hardening_native_release.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Atools/xtask/Cargo.toml | 21+++++++++++++++++++++
Atools/xtask/src/main.rs | 1662+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()); + } + } +}