lib

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

commit 1e37955cda5c65691fffe3264c8b21aa11c150e1
parent 205ba4d6eb71100a28f4d8cf47ee613b42a9938d
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 00:50:17 +0000

build: govern catalog-driven artifact controls

- Drive Cargo, Nix, coverage, and preflight from catalog groups.
- Add typed source locks, exact-revision caches, and offline verification.
- Add atomic artifact manifests and immutable source archive controls.
- Qualify deterministic generation, Nix source exports, and negative paths.

Diffstat:
MCargo.lock | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 20++++++++++++++++++++
Mbuild/nix/checks.nix | 10+++++++---
Mbuild/nix/common.nix | 44++++++++++++++++++++++++--------------------
Mcontracts/crates/catalog.v1.toml | 62+++++++++++++++++++++++++++++++-------------------------------
Mcontracts/crates/generated/package_groups.v1.toml | 26+++++++++++++++++++++++++-
Mcontracts/crates/generated/platform_inventory.v1.toml | 2+-
Mcontracts/crates/generated/release_inventory.v2.toml | 2+-
Mtools/xtask/Cargo.toml | 1+
Atools/xtask/src/build_control.rs | 1322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/catalog.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtools/xtask/src/coverage.rs | 11++++++++---
Mtools/xtask/src/main.rs | 392+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
13 files changed, 2087 insertions(+), 86 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -88,6 +88,56 @@ dependencies = [ ] [[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -462,12 +512,58 @@ dependencies = [ ] [[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1644,6 +1740,12 @@ dependencies = [ ] [[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2187,6 +2289,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3668,6 +3776,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4117,6 +4231,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] name = "uuid" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4731,6 +4851,7 @@ dependencies = [ name = "xtask" version = "0.1.0-alpha" dependencies = [ + "clap", "dto_bindgen_core", "fs2", "hex", diff --git a/Cargo.toml b/Cargo.toml @@ -38,6 +38,26 @@ members = [ "crates/mesh_agent_proto", "tools/xtask", ] +default-members = [ + "crates/core", + "crates/identity", + "crates/blossom", + "crates/protocol", + "crates/event", + "crates/event_codec", + "crates/trade", + "crates/signing", + "crates/transport", + "crates/nostr", + "crates/nostr_connect", + "crates/secrets", + "crates/storage", + "crates/storage_sqlite", + "crates/transport_nostr", + "crates/sync", + "crates/geonames", + "tools/xtask", +] resolver = "3" [workspace.package] diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -8,7 +8,8 @@ let pname = "radroots-cargo-check"; doCheck = false; buildPhaseCargoCommand = '' - cargo check --workspace --all-targets + cargo check --locked --all-targets ${common.publicNativeCargoArgs} + cargo check --locked --all-targets ${common.previewCargoArgs} ''; installPhaseCommand = "mkdir -p $out"; } @@ -20,7 +21,10 @@ let pname = "radroots-cargo-test"; doCheck = false; buildPhaseCargoCommand = '' - cargo test ${common.coreContractCargoArgs} + cargo test --locked ${common.coreContractCargoArgs} + cargo test --locked ${common.previewCargoArgs} + cargo clippy --locked --all-targets ${common.publicNativeCargoArgs} -- -D warnings + cargo clippy --locked --all-targets ${common.previewCargoArgs} -- -D warnings ''; installPhaseCommand = "mkdir -p $out"; } @@ -194,7 +198,7 @@ in pname = "radroots-architecture"; doCheck = false; buildPhaseCargoCommand = '' - cargo run --locked -q -p xtask -- architecture-ci + cargo run --locked -q -p xtask -- architecture-source-export-ci ''; installPhaseCommand = "mkdir -p $out"; } diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -7,6 +7,18 @@ let root = ../..; cargoToml = builtins.fromTOML (builtins.readFile ../../Cargo.toml); + packageGroupProjection = builtins.fromTOML ( + builtins.readFile ../../contracts/crates/generated/package_groups.v1.toml + ); + packageGroups = builtins.listToAttrs ( + map (group: { + name = group.id; + value = group.active_packages; + }) packageGroupProjection.group + ); + cargoArgsFor = packages: lib.concatStringsSep " " (map (package: "-p ${package}") packages); + publicNativeCargoArgs = cargoArgsFor packageGroups.public_native; + previewCargoArgs = cargoArgsFor packageGroups.preview; version = cargoToml.workspace.package.version; darwinBuildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv @@ -22,14 +34,16 @@ let ../../CHANGELOG.md ../../LICENSE-APACHE ../../LICENSE-MIT - ../../README + ../../README.md ../../dto_bindgen.toml ../../rust-toolchain.toml ../../contracts ../../crates + ../../docs/api ../../docs/decisions ../../docs/implementation ../../docs/specs + ../../fuzz ../../tools ] ); @@ -93,23 +107,7 @@ let cargoLlvmCov ]; releaseRuntimeInputs = coverageRuntimeInputs; - coreContractCrates = [ - "xtask" - "radroots_blossom" - "radroots_core" - "radroots_event" - "radroots_trade" - "radroots_identity" - "radroots_replica_schema" - "radroots_event_codec" - "radroots_event_store" - "radroots_nostr" - "radroots_nostr_connect" - "radroots_nostr_signer" - ]; - coreContractCargoArgs = - lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) - + " --features radroots_event_codec/json,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events"; + coreContractCargoArgs = publicNativeCargoArgs; craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable; commonCraneArgs = { inherit version; @@ -184,7 +182,8 @@ let export RADROOTS_WORKSPACE_ROOT="$PWD" ''; checkCommand = '' - cargo check --workspace --all-targets + cargo check --locked --all-targets ${publicNativeCargoArgs} + cargo check --locked --all-targets ${previewCargoArgs} ''; architectureCommand = '' cargo run --locked -q -p xtask -- architecture-ci @@ -196,7 +195,8 @@ let cargo run -q -p xtask -- contract validate ''; releasePreflightCommand = '' - cargo check -q + cargo check -q --locked ${publicNativeCargoArgs} + cargo check -q --locked ${previewCargoArgs} cargo test -q -p xtask cargo run -q -p xtask -- contract validate @@ -365,6 +365,7 @@ in { inherit architectureCommand + cargoArgsFor cargoLlvmCov cargoArtifacts checkCommand @@ -377,6 +378,9 @@ in mkRepoCheck releasePreflightCommand coreContractCargoArgs + packageGroups + previewCargoArgs + publicNativeCargoArgs sharedEnv version xtaskPackage diff --git a/contracts/crates/catalog.v1.toml b/contracts/crates/catalog.v1.toml @@ -36,7 +36,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["core"] permitted_dependency_tiers = ["foundation"] source_repository = "https://github.com/radrootslabs/lib" @@ -56,7 +56,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["identity"] permitted_dependency_tiers = ["foundation"] source_repository = "https://github.com/radrootslabs/lib" @@ -76,7 +76,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["protocol"] permitted_dependency_tiers = ["foundation"] source_repository = "https://github.com/radrootslabs/lib" @@ -96,7 +96,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["protocol"] permitted_dependency_tiers = ["foundation"] source_repository = "https://github.com/radrootslabs/lib" @@ -116,7 +116,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["event"] permitted_dependency_tiers = ["foundation", "domain"] source_repository = "https://github.com/radrootslabs/lib" @@ -136,7 +136,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["event"] permitted_dependency_tiers = ["foundation", "domain"] source_repository = "https://github.com/radrootslabs/lib" @@ -156,7 +156,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["trade"] permitted_dependency_tiers = ["foundation", "domain"] source_repository = "https://github.com/radrootslabs/lib" @@ -176,7 +176,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["signing"] permitted_dependency_tiers = ["foundation", "domain", "spi"] source_repository = "https://github.com/radrootslabs/lib" @@ -196,7 +196,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["transport"] permitted_dependency_tiers = ["foundation", "domain", "spi"] source_repository = "https://github.com/radrootslabs/lib" @@ -216,7 +216,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["security"] permitted_dependency_tiers = ["foundation", "domain", "spi"] source_repository = "https://github.com/radrootslabs/lib" @@ -236,7 +236,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["storage"] permitted_dependency_tiers = ["foundation", "domain", "spi"] source_repository = "https://github.com/radrootslabs/lib" @@ -256,7 +256,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["nostr"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -276,7 +276,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["nostr"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -296,7 +296,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["storage"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -316,7 +316,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["transport"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -336,7 +336,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["geonames"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -356,7 +356,7 @@ publish = true version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["portable", "public_native"] +groups = ["coverage_required", "portable", "public_native"] owners = ["sync"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration"] source_repository = "https://github.com/radrootslabs/lib" @@ -376,7 +376,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -396,7 +396,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -536,7 +536,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -556,7 +556,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -576,7 +576,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "fixture"] source_repository = "https://github.com/radrootslabs/lib" @@ -596,7 +596,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -616,7 +616,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -636,7 +636,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["preview"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] source_repository = "https://github.com/radrootslabs/lib" @@ -656,7 +656,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["runtime"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] source_repository = "https://github.com/radrootslabs/lib" @@ -676,7 +676,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["runtime"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] source_repository = "https://github.com/radrootslabs/lib" @@ -696,7 +696,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["runtime"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] source_repository = "https://github.com/radrootslabs/lib" @@ -716,7 +716,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native", "wasm32"] -groups = ["preview", "wasm"] +groups = ["coverage_required", "preview", "wasm"] owners = ["storage"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter"] source_repository = "https://github.com/radrootslabs/lib" @@ -736,7 +736,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["any"] -groups = ["preview"] +groups = ["coverage_required", "preview"] owners = ["testing"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime", "fixture"] source_repository = "https://github.com/radrootslabs/lib" @@ -756,7 +756,7 @@ publish = false version = "0.1.0-alpha" license = "MIT OR Apache-2.0" platforms = ["native"] -groups = ["tools"] +groups = ["coverage_required", "tools"] owners = ["architecture"] permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime", "fixture", "tool"] source_repository = "https://github.com/radrootslabs/lib" diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml @@ -1,38 +1,62 @@ schema = "radroots.workspace.package-groups.v1" -catalog_sha256 = "1a244e2e7f14a20ee21917a246c03277920c927c138796440483bf7837555fcf" +catalog_sha256 = "0b1d39e687f499a58c7d3a1ffee6b8c07210b61d3b94c7c2a18681fe3a005ace" [[group]] id = "boundaries" packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_studio_ffi", "radroots_studio_uniffi_bindgen", "radroots_trade_bindings"] +active_packages = [] +reserved_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_studio_ffi", "radroots_studio_uniffi_bindgen", "radroots_trade_bindings"] + +[[group]] +id = "coverage_required" +packages = ["radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_secrets", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] +active_packages = ["radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_secrets", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] +reserved_packages = [] [[group]] id = "mobile" packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"] +active_packages = [] +reserved_packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"] [[group]] id = "portable" packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] +active_packages = ["radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] +reserved_packages = ["radroots", "radroots_sdk"] [[group]] id = "preview" packages = ["radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_transport_reticulum"] +active_packages = ["radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_transport_reticulum"] +reserved_packages = [] [[group]] id = "public_native" packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] +active_packages = ["radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] +reserved_packages = ["radroots", "radroots_sdk"] [[group]] id = "sdk" packages = ["radroots", "radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_trade_bindings"] +active_packages = [] +reserved_packages = ["radroots", "radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_trade_bindings"] [[group]] id = "studio" packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"] +active_packages = [] +reserved_packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"] [[group]] id = "tools" packages = ["xtask"] +active_packages = ["xtask"] +reserved_packages = [] [[group]] id = "wasm" packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] +active_packages = ["radroots_sql_core"] +reserved_packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime"] diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.platform-inventory.v1" -catalog_sha256 = "1a244e2e7f14a20ee21917a246c03277920c927c138796440483bf7837555fcf" +catalog_sha256 = "0b1d39e687f499a58c7d3a1ffee6b8c07210b61d3b94c7c2a18681fe3a005ace" [[platform]] id = "android" diff --git a/contracts/crates/generated/release_inventory.v2.toml b/contracts/crates/generated/release_inventory.v2.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.release-inventory.v2" -catalog_sha256 = "1a244e2e7f14a20ee21917a246c03277920c927c138796440483bf7837555fcf" +catalog_sha256 = "0b1d39e687f499a58c7d3a1ffee6b8c07210b61d3b94c7c2a18681fe3a005ace" architecture = "radroots.crates.release.v2" version = "0.1.0-alpha" public_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -10,6 +10,7 @@ publish = false authors = ["Tyson Lupul <tyson@radroots.org>"] [dependencies] +clap = { workspace = true, features = ["derive"] } dto_bindgen_core = { workspace = true } fs2 = { workspace = true } hex = { workspace = true } diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs @@ -0,0 +1,1322 @@ +use std::{ + collections::BTreeSet, + fs, + io::Write, + path::{Component, Path, PathBuf}, + process::Command, +}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const SOURCE_LOCK_NAME: &str = "radroots.lib.source-lock.v1.toml"; +const CONSUMER_MARKER: &str = ".radroots-consumer-root"; +const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v1.toml"; +const REPOSITORY: &str = "https://github.com/radrootslabs/lib"; +const ARCHITECTURE: &str = "radroots.crates.release.v2"; +const VERSION: &str = "0.1.0-alpha"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Operation { + Check, + Test, + Clippy, +} + +impl Operation { + pub fn cargo_subcommand(self) -> &'static str { + match self { + Self::Check => "check", + Self::Test => "test", + Self::Clippy => "clippy", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Mode { + Check, + Write, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageGroups { + schema: String, + catalog_sha256: String, + group: Vec<PackageGroup>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageGroup { + id: String, + packages: Vec<String>, + active_packages: Vec<String>, + reserved_packages: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SourceLock { + pub schema: String, + pub repository: String, + pub revision: String, + pub architecture: String, + pub workspace_catalog_sha256: String, + pub version: String, + pub source_archive_sha256: Option<String>, + pub lockfile_sha256: String, +} + +#[derive(Clone, Debug)] +pub struct ConsumerRoot { + path: PathBuf, + product: String, + source_lock: SourceLock, +} + +impl ConsumerRoot { + pub fn open(path: &Path) -> Result<Self, String> { + require_absolute_real_directory(path, "consumer root")?; + let canonical = fs::canonicalize(path) + .map_err(|error| format!("canonicalize consumer root {}: {error}", path.display()))?; + let marker = read_regular_no_follow(&canonical.join(CONSUMER_MARKER))?; + let product = String::from_utf8(marker) + .map_err(|error| format!("consumer marker is not UTF-8: {error}"))? + .trim() + .to_owned(); + if !matches!(product.as_str(), "sdk" | "mobile" | "studio") { + return Err("consumer marker must contain sdk, mobile, or studio".to_owned()); + } + let source_lock_path = canonical.join(SOURCE_LOCK_NAME); + let source_lock = parse_source_lock(&source_lock_path)?; + validate_source_lock(&source_lock)?; + validate_consumer_files(&canonical, &source_lock)?; + Ok(Self { + path: canonical, + product, + source_lock, + }) + } + + fn output(&self, relative: &Path) -> Result<PathBuf, String> { + validate_relative_path(relative, "artifact output")?; + let output = self.path.join(relative); + ensure_no_symlink_components(&self.path, relative)?; + Ok(output) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct CacheManifest { + schema: String, + repository: String, + revision: String, + workspace_catalog_sha256: String, + source_archive_sha256: Option<String>, + tree: String, +} + +#[derive(Debug, Deserialize)] +struct CargoLock { + #[serde(default)] + package: Vec<CargoLockPackage>, +} + +#[derive(Debug, Deserialize)] +struct CargoLockPackage { + source: Option<String>, +} + +#[derive(Debug, Serialize)] +struct ArtifactManifest<'a> { + schema: &'static str, + product: &'a str, + target: &'a str, + language: &'a str, + external_names: Vec<&'a str>, + files: Vec<ArtifactFile>, + provenance: Provenance<'a>, +} + +#[derive(Debug, Serialize)] +struct ArtifactFile { + path: String, + bytes: u64, + sha256: String, +} + +#[derive(Debug, Serialize)] +struct Provenance<'a> { + repository: &'a str, + revision: &'a str, + architecture: &'a str, + catalog_sha256: &'a str, + lockfile_sha256: &'a str, + source_archive_sha256: Option<&'a str>, + source_date_epoch: u64, + builder_id: &'a str, + features: Vec<&'a str>, +} + +pub fn group_plan( + workspace_root: &Path, + group: &str, + operation: Operation, + include_reserved: bool, +) -> Result<Vec<String>, String> { + validate_identifier(group, "group")?; + let projection_path = workspace_root.join("contracts/crates/generated/package_groups.v1.toml"); + let bytes = read_regular_no_follow(&projection_path)?; + let raw = std::str::from_utf8(&bytes) + .map_err(|error| format!("package group projection is not UTF-8: {error}"))?; + let projection = toml::from_str::<PackageGroups>(raw) + .map_err(|error| format!("parse package group projection: {error}"))?; + if projection.schema != "radroots.workspace.package-groups.v1" { + return Err("package group projection schema drifted".to_owned()); + } + validate_sha256(&projection.catalog_sha256, "catalog digest")?; + let catalog = read_regular_no_follow(&workspace_root.join(CATALOG_RELATIVE))?; + if sha256(&catalog) != projection.catalog_sha256 { + return Err("package group projection is stale".to_owned()); + } + let selected = projection + .group + .iter() + .find(|candidate| candidate.id == group) + .ok_or_else(|| format!("unknown catalog group {group}"))?; + let expected = selected + .active_packages + .iter() + .chain(selected.reserved_packages.iter()) + .cloned() + .collect::<BTreeSet<_>>(); + if selected.packages.iter().cloned().collect::<BTreeSet<_>>() != expected + || selected.active_packages.is_empty() + { + return Err(format!("catalog group {group} is malformed or not active")); + } + let packages = if include_reserved { + &selected.packages + } else { + &selected.active_packages + }; + let mut plan = vec![ + operation.cargo_subcommand().to_owned(), + "--locked".to_owned(), + ]; + if operation != Operation::Test { + plan.push("--all-targets".to_owned()); + } + for package in packages { + plan.push("-p".to_owned()); + plan.push(package.clone()); + } + if operation == Operation::Clippy { + plan.push("--".to_owned()); + plan.push("-D".to_owned()); + plan.push("warnings".to_owned()); + } + Ok(plan) +} + +pub fn execute_group_plan(workspace_root: &Path, plan: &[String]) -> Result<(), String> { + let output = Command::new("cargo") + .args(plan) + .current_dir(workspace_root) + .status() + .map_err(|error| format!("run catalog group plan: {error}"))?; + if output.success() { + Ok(()) + } else { + Err(format!("catalog group plan failed with {output}")) + } +} + +pub fn print_plan(plan: &[String]) { + println!("cargo {}", plan.join(" ")); +} + +pub fn validate_consumer(path: &Path) -> Result<ConsumerRoot, String> { + ConsumerRoot::open(path) +} + +pub fn materialize( + consumer_path: &Path, + cache_root: &Path, + offline: bool, +) -> Result<PathBuf, String> { + let consumer = ConsumerRoot::open(consumer_path)?; + materialize_from( + &consumer, + cache_root, + offline, + &consumer.source_lock.repository, + ) +} + +fn materialize_from( + consumer: &ConsumerRoot, + cache_root: &Path, + offline: bool, + fetch_url: &str, +) -> Result<PathBuf, String> { + require_absolute_real_directory(cache_root, "cache root")?; + let key = format!( + "{}-{}", + consumer.source_lock.revision, consumer.source_lock.workspace_catalog_sha256 + ); + let destination = cache_root.join(key); + let lock_path = cache_root.join(".radroots-source-cache.lock"); + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| format!("open source cache lock: {error}"))?; + lock.lock_exclusive() + .map_err(|error| format!("lock source cache: {error}"))?; + let result = if destination.exists() { + verify_cache(&destination, &consumer.source_lock).map(|()| destination.clone()) + } else if offline { + Err("offline source materialization requires a verified cache entry".to_owned()) + } else { + prefetch_cache(cache_root, &destination, &consumer.source_lock, fetch_url)?; + verify_cache(&destination, &consumer.source_lock)?; + Ok(destination.clone()) + }; + FileExt::unlock(&lock).map_err(|error| format!("unlock source cache: {error}"))?; + result +} + +fn prefetch_cache( + cache_root: &Path, + destination: &Path, + source_lock: &SourceLock, + fetch_url: &str, +) -> Result<(), String> { + let staging = tempfile::Builder::new() + .prefix(".radroots-source-stage-") + .tempdir_in(cache_root) + .map_err(|error| format!("create source cache staging directory: {error}"))?; + git(staging.path(), &["init", "--quiet"])?; + git(staging.path(), &["remote", "add", "origin", fetch_url])?; + git( + staging.path(), + &[ + "fetch", + "--quiet", + "--depth=1", + "origin", + &source_lock.revision, + ], + )?; + git( + staging.path(), + &["checkout", "--quiet", "--detach", "FETCH_HEAD"], + )?; + let tree = git_stdout(staging.path(), &["rev-parse", "HEAD^{tree}"])?; + let manifest = CacheManifest { + schema: "radroots.source-cache.v1".to_owned(), + repository: source_lock.repository.clone(), + revision: source_lock.revision.clone(), + workspace_catalog_sha256: source_lock.workspace_catalog_sha256.clone(), + source_archive_sha256: source_lock.source_archive_sha256.clone(), + tree: tree.trim().to_owned(), + }; + let raw = toml::to_string(&manifest) + .map_err(|error| format!("serialize source cache manifest: {error}"))?; + atomic_write( + &staging.path().join(".radroots-source-cache.v1.toml"), + raw.as_bytes(), + )?; + let staging_path = staging.keep(); + fs::rename(&staging_path, destination).map_err(|error| { + format!( + "install source cache {} -> {}: {error}", + staging_path.display(), + destination.display() + ) + })?; + set_readonly_tree(destination) +} + +fn verify_cache(path: &Path, source_lock: &SourceLock) -> Result<(), String> { + require_absolute_real_directory(path, "cache entry")?; + let head = git_stdout(path, &["rev-parse", "HEAD"])?; + let tree = git_stdout(path, &["rev-parse", "HEAD^{tree}"])?; + if head.trim() != source_lock.revision { + return Err("source cache revision drifted".to_owned()); + } + git(path, &["diff", "--quiet", "--no-ext-diff"])?; + git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?; + verify_untracked_cache_paths(path)?; + let manifest_path = path.join(".radroots-source-cache.v1.toml"); + let bytes = read_regular_no_follow(&manifest_path)?; + let raw = std::str::from_utf8(&bytes) + .map_err(|error| format!("source cache manifest is not UTF-8: {error}"))?; + let manifest = toml::from_str::<CacheManifest>(raw) + .map_err(|error| format!("parse source cache manifest: {error}"))?; + if manifest.schema != "radroots.source-cache.v1" + || manifest.repository != source_lock.repository + || manifest.revision != source_lock.revision + || manifest.workspace_catalog_sha256 != source_lock.workspace_catalog_sha256 + || manifest.source_archive_sha256 != source_lock.source_archive_sha256 + || manifest.tree != tree.trim() + { + return Err("source cache manifest drifted".to_owned()); + } + let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?; + if sha256(&catalog) != source_lock.workspace_catalog_sha256 { + return Err("source cache catalog digest drifted".to_owned()); + } + Ok(()) +} + +pub fn verify_source_archive(path: &Path, expected_sha256: &str) -> Result<(), String> { + if !path.is_absolute() { + return Err("source archive path must be absolute".to_owned()); + } + validate_sha256(expected_sha256, "source archive digest")?; + let bytes = read_regular_no_follow(path)?; + if sha256(&bytes) != expected_sha256 { + return Err("source archive digest drifted".to_owned()); + } + verify_bundle(path) +} + +pub fn create_source_archive( + source_root: &Path, + revision: &str, + output_path: &Path, +) -> Result<String, String> { + require_absolute_real_directory(source_root, "archive source root")?; + validate_oid(revision, "archive revision")?; + if !output_path.is_absolute() { + return Err("source archive output must be absolute".to_owned()); + } + git( + source_root, + &["cat-file", "-e", &format!("{revision}^{{commit}}")], + )?; + let parent = output_path + .parent() + .ok_or_else(|| "source archive output has no parent".to_owned())?; + create_directories_no_follow(parent)?; + if let Ok(metadata) = fs::symlink_metadata(output_path) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err("source archive output must be a regular file".to_owned()); + } + let temporary = tempfile::Builder::new() + .prefix(".radroots-source-archive-") + .tempfile_in(parent) + .map_err(|error| format!("stage source archive: {error}"))?; + let temporary_path = temporary.path().to_path_buf(); + temporary + .close() + .map_err(|error| format!("prepare source archive staging path: {error}"))?; + let archive_repo = tempfile::TempDir::new_in(parent) + .map_err(|error| format!("create archive staging repository: {error}"))?; + git(archive_repo.path(), &["init", "--bare", "--quiet"])?; + let fetch = Command::new("git") + .args(["fetch", "--quiet", "--no-tags"]) + .arg(source_root) + .arg(format!("{revision}:refs/heads/archive")) + .current_dir(archive_repo.path()) + .output() + .map_err(|error| format!("stage archive revision: {error}"))?; + if !fetch.status.success() { + return Err(format!( + "stage archive revision failed: {}", + String::from_utf8_lossy(&fetch.stderr).trim() + )); + } + let output = Command::new("git") + .args(["bundle", "create"]) + .arg(&temporary_path) + .arg("refs/heads/archive") + .current_dir(archive_repo.path()) + .output() + .map_err(|error| format!("create source archive: {error}"))?; + if !output.status.success() { + let _ = fs::remove_file(&temporary_path); + return Err(format!( + "create source archive failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let bytes = read_regular_no_follow(&temporary_path)?; + let digest = sha256(&bytes); + verify_bundle(&temporary_path)?; + let listed = Command::new("git") + .args(["bundle", "list-heads"]) + .arg(&temporary_path) + .output() + .map_err(|error| format!("list source archive heads: {error}"))?; + if !listed.status.success() + || String::from_utf8_lossy(&listed.stdout).trim() + != format!("{revision} refs/heads/archive") + { + let _ = fs::remove_file(&temporary_path); + return Err("source archive does not contain exactly the requested revision".to_owned()); + } + if output_path.exists() { + let existing = read_regular_no_follow(output_path)?; + let _ = fs::remove_file(&temporary_path); + if existing == bytes { + return Ok(digest); + } + return Err( + "immutable source archive output already exists with different bytes".to_owned(), + ); + } + fs::File::open(&temporary_path) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("sync source archive: {error}"))?; + fs::rename(&temporary_path, output_path) + .map_err(|error| format!("install source archive: {error}"))?; + Ok(digest) +} + +fn verify_bundle(path: &Path) -> Result<(), String> { + let verification_repo = tempfile::TempDir::new() + .map_err(|error| format!("create bundle verification repository: {error}"))?; + git(verification_repo.path(), &["init", "--bare", "--quiet"])?; + let output = Command::new("git") + .args(["bundle", "verify"]) + .arg(path) + .current_dir(verification_repo.path()) + .output() + .map_err(|error| format!("run git bundle verify: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "source archive is not a valid Git bundle: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn artifact( + product: &str, + target: &str, + language: &str, + mode: Mode, + consumer_path: &Path, + source_path: &Path, + output_relative: &Path, + source_date_epoch: u64, + builder_id: &str, + features: &[String], +) -> Result<(), String> { + validate_identifier(product, "product")?; + validate_identifier(target, "target")?; + validate_identifier(language, "language")?; + validate_identifier(builder_id, "builder id")?; + validate_artifact_route(product, target, language)?; + let consumer = ConsumerRoot::open(consumer_path)?; + if consumer.product != product { + return Err(format!( + "consumer marker {} does not match artifact product {product}", + consumer.product + )); + } + verify_source_root(source_path, &consumer.source_lock)?; + let mut sorted_features = features.iter().map(String::as_str).collect::<Vec<_>>(); + sorted_features.sort_unstable(); + sorted_features.dedup(); + for feature in &sorted_features { + validate_identifier(feature, "feature")?; + } + let external_names = match product { + "sdk" => vec!["radroots", "radroots_sdk"], + "mobile" => vec!["RadrootsFFI", "RadrootsKitBindings"], + "studio" => vec!["org.radroots.studio.ffi", "radroots_studio_ffi"], + _ => return Err("unsupported artifact product".to_owned()), + }; + let manifest = ArtifactManifest { + schema: "radroots.artifact-manifest.v1", + product, + target, + language, + external_names, + files: Vec::new(), + provenance: Provenance { + repository: &consumer.source_lock.repository, + revision: &consumer.source_lock.revision, + architecture: &consumer.source_lock.architecture, + catalog_sha256: &consumer.source_lock.workspace_catalog_sha256, + lockfile_sha256: &consumer.source_lock.lockfile_sha256, + source_archive_sha256: consumer.source_lock.source_archive_sha256.as_deref(), + source_date_epoch, + builder_id, + features: sorted_features, + }, + }; + let mut bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("serialize artifact manifest: {error}"))?; + bytes.push(b'\n'); + let output = consumer.output(output_relative)?; + match mode { + Mode::Check => { + let current = read_regular_no_follow(&output)?; + if current == bytes { + Ok(()) + } else { + Err(format!("artifact manifest {} is stale", output.display())) + } + } + Mode::Write => atomic_write(&output, &bytes), + } +} + +fn validate_artifact_route(product: &str, target: &str, language: &str) -> Result<(), String> { + let valid = match product { + "sdk" => matches!(target, "typescript" | "wasm" | "ffi") && language == "typescript", + "mobile" => matches!( + (target, language), + ("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript") + ), + "studio" => matches!(target, "linux" | "macos" | "windows") && language == "kotlin", + _ => false, + }; + if valid { + Ok(()) + } else { + Err(format!( + "unsupported artifact route {product}/{target}/{language}" + )) + } +} + +fn verify_source_root(path: &Path, source_lock: &SourceLock) -> Result<(), String> { + require_absolute_real_directory(path, "source root")?; + if git_stdout(path, &["rev-parse", "HEAD"])?.trim() != source_lock.revision { + return Err("source root revision does not match source lock".to_owned()); + } + let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?; + if sha256(&catalog) != source_lock.workspace_catalog_sha256 { + return Err("source root catalog does not match source lock".to_owned()); + } + git(path, &["diff", "--quiet", "--no-ext-diff"])?; + git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?; + verify_untracked_cache_paths(path) +} + +fn verify_untracked_cache_paths(path: &Path) -> Result<(), String> { + let status = git_stdout(path, &["status", "--porcelain", "--untracked-files=all"])?; + for line in status.lines() { + if line != "?? .radroots-source-cache.v1.toml" { + return Err(format!("source tree contains ungoverned change {line}")); + } + } + Ok(()) +} + +fn parse_source_lock(path: &Path) -> Result<SourceLock, String> { + let bytes = read_regular_no_follow(path)?; + let raw = std::str::from_utf8(&bytes) + .map_err(|error| format!("source lock is not UTF-8: {error}"))?; + toml::from_str(raw).map_err(|error| format!("parse source lock {}: {error}", path.display())) +} + +fn validate_source_lock(source_lock: &SourceLock) -> Result<(), String> { + if source_lock.schema != "radroots.lib.source-lock.v1" + || source_lock.repository != REPOSITORY + || source_lock.architecture != ARCHITECTURE + || source_lock.version != VERSION + { + return Err( + "source lock identity, repository, architecture, or version drifted".to_owned(), + ); + } + validate_oid(&source_lock.revision, "source lock revision")?; + validate_sha256( + &source_lock.workspace_catalog_sha256, + "source lock catalog digest", + )?; + validate_sha256(&source_lock.lockfile_sha256, "source lock lockfile digest")?; + if let Some(digest) = &source_lock.source_archive_sha256 { + validate_sha256(digest, "source lock archive digest")?; + } + Ok(()) +} + +fn validate_consumer_files(root: &Path, source_lock: &SourceLock) -> Result<(), String> { + let lockfile = root.join("Cargo.lock"); + let lockfile_bytes = read_regular_no_follow(&lockfile)?; + if sha256(&lockfile_bytes) != source_lock.lockfile_sha256 { + return Err("consumer Cargo.lock digest drifted".to_owned()); + } + let lockfile_raw = std::str::from_utf8(&lockfile_bytes) + .map_err(|error| format!("consumer Cargo.lock is not UTF-8: {error}"))?; + let cargo_lock = toml::from_str::<CargoLock>(lockfile_raw) + .map_err(|error| format!("parse consumer Cargo.lock: {error}"))?; + let expected_source = format!( + "git+{}?rev={}#{}", + source_lock.repository, source_lock.revision, source_lock.revision + ); + let mut lock_source_count = 0_usize; + for source in cargo_lock + .package + .iter() + .filter_map(|package| package.source.as_deref()) + .filter(|source| source.contains("radrootslabs/lib")) + { + lock_source_count += 1; + if source != expected_source { + return Err("consumer Cargo.lock contains a mixed or floating lib source".to_owned()); + } + } + if lock_source_count == 0 { + return Err("consumer Cargo.lock contains no canonical lib source".to_owned()); + } + let mut manifests = Vec::new(); + collect_manifests(root, root, &mut manifests)?; + if manifests.is_empty() { + return Err("consumer root contains no Cargo manifest".to_owned()); + } + let mut dependency_count = 0_usize; + for manifest in manifests { + let bytes = read_regular_no_follow(&manifest)?; + let raw = std::str::from_utf8(&bytes) + .map_err(|error| format!("consumer manifest is not UTF-8: {error}"))?; + let value = toml::from_str::<toml::Value>(raw) + .map_err(|error| format!("parse consumer manifest {}: {error}", manifest.display()))?; + validate_manifest_value(&value, source_lock, &mut dependency_count)?; + } + if dependency_count == 0 { + return Err("consumer manifests contain no canonical lib dependency".to_owned()); + } + Ok(()) +} + +fn collect_manifests(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> { + for entry in fs::read_dir(current) + .map_err(|error| format!("read consumer directory {}: {error}", current.display()))? + { + let entry = entry.map_err(|error| format!("read consumer directory entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?; + if file_type.is_symlink() { + return Err(format!( + "consumer tree contains symlink {}", + entry.path().display() + )); + } + let name = entry.file_name(); + if file_type.is_dir() { + if matches!( + name.to_str(), + Some(".git" | "target" | "node_modules" | ".radroots") + ) { + continue; + } + collect_manifests(root, &entry.path(), output)?; + } else if name == "Cargo.toml" { + entry + .path() + .strip_prefix(root) + .map_err(|_| "consumer manifest escaped root".to_owned())?; + output.push(entry.path()); + } + } + output.sort(); + Ok(()) +} + +fn validate_manifest_value( + value: &toml::Value, + source_lock: &SourceLock, + dependency_count: &mut usize, +) -> Result<(), String> { + match value { + toml::Value::Table(table) => { + let structured_lib_url = table + .get("git") + .and_then(toml::Value::as_str) + .filter(|git| git.contains("radrootslabs/lib")); + if let Some(git) = structured_lib_url { + *dependency_count += 1; + if git != source_lock.repository + || table.get("rev").and_then(toml::Value::as_str) + != Some(source_lock.revision.as_str()) + || table.get("version").and_then(toml::Value::as_str) != Some("=0.1.0-alpha") + || table.contains_key("branch") + || table.contains_key("tag") + || table.contains_key("path") + { + return Err( + "consumer manifest contains a mixed or floating lib dependency".to_owned(), + ); + } + } + for (key, child) in table { + if structured_lib_url.is_some() && key == "git" { + continue; + } + if key == "patch" && format!("{child:?}").contains("radrootslabs/lib") { + return Err("consumer manifest contains a lib patch override".to_owned()); + } + validate_manifest_value(child, source_lock, dependency_count)?; + } + } + toml::Value::Array(values) => { + for child in values { + validate_manifest_value(child, source_lock, dependency_count)?; + } + } + toml::Value::String(value) if value.contains("radrootslabs/lib") => { + return Err("consumer manifest contains an unstructured lib source".to_owned()); + } + _ => {} + } + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + if bytes.contains(&b'\r') || !bytes.ends_with(b"\n") { + return Err("generated text must use LF and end with one newline".to_owned()); + } + let parent = path + .parent() + .ok_or_else(|| format!("output {} has no parent", path.display()))?; + create_directories_no_follow(parent)?; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("output {} must be a regular file", path.display())); + } + if fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))? == bytes { + return Ok(()); + } + } + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| format!("stage output in {}: {error}", parent.display()))?; + temporary + .write_all(bytes) + .map_err(|error| format!("stage output {}: {error}", path.display()))?; + temporary + .as_file() + .sync_all() + .map_err(|error| format!("sync staged output {}: {error}", path.display()))?; + temporary + .persist(path) + .map_err(|error| format!("replace output {}: {}", path.display(), error.error))?; + Ok(()) +} + +fn create_directories_no_follow(path: &Path) -> Result<(), String> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(&current) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(format!( + "output parent {} is not a real directory", + current.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&current).map_err(|error| { + format!("create output directory {}: {error}", current.display()) + })?; + } + Err(error) => { + return Err(format!( + "inspect output directory {}: {error}", + current.display() + )); + } + } + } + Ok(()) +} + +fn ensure_no_symlink_components(root: &Path, relative: &Path) -> Result<(), String> { + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(&current) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "artifact path contains symlink {}", + current.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(format!( + "inspect artifact path {}: {error}", + current.display() + )); + } + } + } + Ok(()) +} + +fn read_regular_no_follow(path: &Path) -> Result<Vec<u8>, String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "{} must be a regular non-symlink file", + path.display() + )); + } + fs::read(path).map_err(|error| format!("read {}: {error}", path.display())) +} + +fn require_absolute_real_directory(path: &Path, label: &str) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label} must be absolute")); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} must be a real directory")); + } + Ok(()) +} + +fn validate_relative_path(path: &Path, label: &str) -> Result<(), String> { + if path.as_os_str().is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("{label} must be a safe relative path")); + } + Ok(()) +} + +fn validate_identifier(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'/') + }) + { + return Err(format!("{label} contains unsupported characters")); + } + Ok(()) +} + +fn validate_oid(value: &str, label: &str) -> Result<(), String> { + if value.len() != 40 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("{label} must be lowercase full 40-hex")); + } + Ok(()) +} + +fn validate_sha256(value: &str, label: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("{label} must be lowercase 64-hex")); + } + Ok(()) +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn git(root: &Path, args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("git {} output is not UTF-8: {error}", args.join(" "))) +} + +fn set_readonly_tree(root: &Path) -> Result<(), String> { + for entry in walk(root)? { + let metadata = fs::symlink_metadata(&entry) + .map_err(|error| format!("inspect cache path {}: {error}", entry.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!("source cache contains symlink {}", entry.display())); + } + let mut permissions = metadata.permissions(); + permissions.set_readonly(true); + fs::set_permissions(&entry, permissions) + .map_err(|error| format!("protect cache path {}: {error}", entry.display()))?; + } + Ok(()) +} + +fn walk(root: &Path) -> Result<Vec<PathBuf>, String> { + let mut pending = vec![root.to_path_buf()]; + let mut output = Vec::new(); + while let Some(path) = pending.pop() { + output.push(path.clone()); + if fs::symlink_metadata(&path) + .map_err(|error| format!("inspect {}: {error}", path.display()))? + .is_dir() + { + for entry in + fs::read_dir(&path).map_err(|error| format!("read {}: {error}", path.display()))? + { + pending.push( + entry + .map_err(|error| format!("read directory entry: {error}"))? + .path(), + ); + } + } + } + output.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture { + _root: tempfile::TempDir, + source: PathBuf, + consumer: PathBuf, + cache: PathBuf, + source_lock: SourceLock, + } + + impl Fixture { + fn new(product: &str) -> Self { + let root = tempfile::TempDir::new().expect("fixture root"); + let source = root.path().join("source"); + let consumer = root.path().join("consumer"); + let cache = root.path().join("cache"); + fs::create_dir(&source).expect("source"); + fs::create_dir(&consumer).expect("consumer"); + fs::create_dir(&cache).expect("cache"); + git(&source, &["init", "--initial-branch=master"]).expect("init"); + git(&source, &["config", "user.name", "Build Control Fixture"]).expect("name"); + git( + &source, + &["config", "user.email", "build-control@radroots.org"], + ) + .expect("email"); + fs::create_dir_all(source.join("contracts/crates")).expect("contracts"); + fs::write( + source.join(CATALOG_RELATIVE), + "schema = \"radroots.workspace.catalog.v1\"\n", + ) + .expect("catalog"); + fs::create_dir_all(source.join("src")).expect("source crate"); + fs::write( + source.join("Cargo.toml"), + "[package]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\nedition = \"2024\"\n", + ) + .expect("source manifest"); + fs::write(source.join("src/lib.rs"), "pub const READY: bool = true;\n") + .expect("source library"); + fs::write( + source.join("Cargo.lock"), + "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\n", + ) + .expect("source lockfile"); + git(&source, &["add", "--all"]).expect("add"); + git(&source, &["commit", "-m", "seed source fixture"]).expect("commit"); + let revision = git_stdout(&source, &["rev-parse", "HEAD"]) + .expect("revision") + .trim() + .to_owned(); + let catalog_sha256 = + sha256(&fs::read(source.join(CATALOG_RELATIVE)).expect("read source catalog")); + fs::write(consumer.join(CONSUMER_MARKER), format!("{product}\n")).expect("marker"); + fs::write( + consumer.join("Cargo.toml"), + format!( + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nradroots_core = {{ git = \"{REPOSITORY}\", rev = \"{revision}\", version = \"=0.1.0-alpha\" }}\n" + ), + ) + .expect("manifest"); + let consumer_lock = format!( + "version = 4\n\n[[package]]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\nsource = \"git+{REPOSITORY}?rev={revision}#{revision}\"\n" + ); + fs::write(consumer.join("Cargo.lock"), &consumer_lock).expect("consumer lockfile"); + let source_lock = SourceLock { + schema: "radroots.lib.source-lock.v1".to_owned(), + repository: REPOSITORY.to_owned(), + revision, + architecture: ARCHITECTURE.to_owned(), + workspace_catalog_sha256: catalog_sha256, + version: VERSION.to_owned(), + source_archive_sha256: None, + lockfile_sha256: sha256(consumer_lock.as_bytes()), + }; + fs::write( + consumer.join(SOURCE_LOCK_NAME), + toml::to_string(&source_lock).expect("serialize source lock"), + ) + .expect("source lock"); + Self { + _root: root, + source, + consumer, + cache, + source_lock, + } + } + } + + #[test] + fn source_lock_and_consumer_root_fail_closed() { + let fixture = Fixture::new("sdk"); + ConsumerRoot::open(&fixture.consumer).expect("valid consumer"); + let mut invalid = fixture.source_lock.clone(); + invalid.revision = "short".to_owned(); + assert!(validate_source_lock(&invalid).is_err()); + + fs::write( + fixture.consumer.join("Cargo.toml"), + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nradroots_core = { git = \"https://github.com/radrootslabs/lib\", branch = \"master\", version = \"*\" }\n", + ) + .expect("floating manifest"); + assert!(ConsumerRoot::open(&fixture.consumer).is_err()); + } + + #[test] + fn materialization_reuses_verified_cache_and_rejects_tampering() { + let fixture = Fixture::new("sdk"); + let consumer = ConsumerRoot::open(&fixture.consumer).expect("consumer"); + let cached = materialize_from( + &consumer, + &fixture.cache, + false, + fixture.source.to_str().expect("source path"), + ) + .expect("prefetch"); + assert_eq!( + materialize_from( + &consumer, + &fixture.cache, + true, + fixture.source.to_str().expect("source path"), + ) + .expect("offline reuse"), + cached + ); + let frozen_target = fixture._root.path().join("frozen-target"); + let frozen = Command::new("cargo") + .args(["check", "--offline", "--frozen", "--locked"]) + .env("CARGO_TARGET_DIR", &frozen_target) + .current_dir(&cached) + .output() + .expect("run frozen offline source smoke"); + assert!( + frozen.status.success(), + "frozen offline source smoke failed: {}", + String::from_utf8_lossy(&frozen.stderr) + ); + let catalog = cached.join(CATALOG_RELATIVE); + make_path_writable(&catalog).expect("make catalog writable"); + fs::write(&catalog, "tampered\n").expect("tamper"); + assert!(verify_cache(&cached, &fixture.source_lock).is_err()); + make_writable(&cached); + } + + #[test] + fn artifact_manifests_are_deterministic_and_route_checked() { + let fixture = Fixture::new("sdk"); + let output = Path::new("generated/artifact-manifest.json"); + artifact( + "sdk", + "typescript", + "typescript", + Mode::Write, + &fixture.consumer, + &fixture.source, + output, + 1_700_000_000, + "fixture_builder", + &["zeta".to_owned(), "alpha".to_owned(), "alpha".to_owned()], + ) + .expect("write artifact manifest"); + artifact( + "sdk", + "typescript", + "typescript", + Mode::Check, + &fixture.consumer, + &fixture.source, + output, + 1_700_000_000, + "fixture_builder", + &["alpha".to_owned(), "zeta".to_owned()], + ) + .expect("check artifact manifest"); + assert!( + validate_artifact_route("mobile", "ios", "kotlin").is_err(), + "unsupported target/language must fail" + ); + assert!( + artifact( + "sdk", + "typescript", + "typescript", + Mode::Write, + &fixture.consumer, + &fixture.source, + Path::new("../escape"), + 1, + "fixture_builder", + &[], + ) + .is_err() + ); + } + + #[test] + fn atomic_outputs_reject_unsafe_text_and_path_collisions() { + let root = tempfile::TempDir::new().expect("output fixture"); + let output = root.path().join("generated/output.json"); + atomic_write(&output, b"prior\n").expect("initial output"); + #[cfg(unix)] + let initial_inode = { + use std::os::unix::fs::MetadataExt; + fs::metadata(&output).expect("initial metadata").ino() + }; + atomic_write(&output, b"prior\n").expect("identical no-op"); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + fs::metadata(&output).expect("no-op metadata").ino(), + initial_inode + ); + } + assert!(atomic_write(&output, b"missing newline").is_err()); + assert!(atomic_write(&output, b"windows\r\n").is_err()); + assert_eq!( + fs::read(&output).expect("prior output retained"), + b"prior\n" + ); + let directory_output = root.path().join("directory-output"); + fs::create_dir_all(&directory_output).expect("directory collision"); + assert!(atomic_write(&directory_output, b"{}\n").is_err()); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let symlink_root = tempfile::TempDir::new().expect("symlink fixture"); + let outside = tempfile::TempDir::new().expect("outside fixture"); + symlink(outside.path(), symlink_root.path().join("generated")) + .expect("output parent symlink"); + assert!( + atomic_write(&symlink_root.path().join("generated/output.json"), b"{}\n").is_err() + ); + } + } + + #[test] + fn source_archive_round_trip_is_digest_bound_and_immutable() { + let fixture = Fixture::new("sdk"); + let archive = fixture._root.path().join("archives/source.bundle"); + let digest = + create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive) + .expect("create archive"); + verify_source_archive(&archive, &digest).expect("verify archive"); + assert_eq!( + create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive,) + .expect("identical archive no-op"), + digest + ); + assert!(verify_source_archive(&archive, &"0".repeat(64)).is_err()); + assert!(verify_source_archive(Path::new("relative.bundle"), &digest).is_err()); + } + + #[cfg(unix)] + #[test] + fn consumer_and_source_roots_reject_symlinks() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new("sdk"); + let consumer_link = fixture._root.path().join("consumer-link"); + symlink(&fixture.consumer, &consumer_link).expect("consumer symlink"); + assert!(ConsumerRoot::open(&consumer_link).is_err()); + + let source_link = fixture._root.path().join("source-link"); + symlink(&fixture.source, &source_link).expect("source symlink"); + assert!(verify_source_root(&source_link, &fixture.source_lock).is_err()); + } + + #[test] + fn checked_in_group_plan_is_explicit_and_active_only() { + let plan = group_plan( + &crate::workspace_root(), + "public_native", + Operation::Check, + false, + ) + .expect("public native plan"); + assert!(plan.iter().any(|arg| arg == "radroots_core")); + assert!(!plan.iter().any(|arg| arg == "radroots_sdk")); + assert!(!plan.iter().any(|arg| arg == "--workspace")); + assert!(group_plan(&crate::workspace_root(), "missing", Operation::Check, false).is_err()); + } + + fn make_writable(root: &Path) { + if let Ok(paths) = walk(root) { + for path in paths { + let _ = make_path_writable(&path); + } + } + } + + fn make_path_writable(path: &Path) -> Result<(), std::io::Error> { + let mut permissions = fs::metadata(path)?.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(permissions.mode() | 0o200); + } + #[cfg(not(unix))] + permissions.set_readonly(false); + fs::set_permissions(path, permissions) + } +} diff --git a/tools/xtask/src/catalog.rs b/tools/xtask/src/catalog.rs @@ -18,6 +18,7 @@ const CONSOLIDATION_RELATIVE: &str = "contracts/consolidation/architecture.v1.to const GROUPS_RELATIVE: &str = "contracts/crates/generated/package_groups.v1.toml"; const PLATFORMS_RELATIVE: &str = "contracts/crates/generated/platform_inventory.v1.toml"; const RELEASE_INVENTORY_RELATIVE: &str = "contracts/crates/generated/release_inventory.v2.toml"; +const COVERAGE_RELATIVE: &str = "contracts/coverage.toml"; const CATALOG_SCHEMA: &str = "radroots.workspace.catalog.v1"; const RELEASE_ID: &str = "radroots.crates.release.v2"; const CONSOLIDATION_ID: &str = "radroots.rust.consolidation.v1"; @@ -104,6 +105,16 @@ struct ConsolidationV1 { } #[derive(Debug, Deserialize)] +struct CoveragePolicy { + required: CoverageRequired, +} + +#[derive(Debug, Deserialize)] +struct CoverageRequired { + crates: Vec<String>, +} + +#[derive(Debug, Deserialize)] struct CargoMetadata { packages: Vec<CargoPackage>, workspace_members: Vec<String>, @@ -138,7 +149,45 @@ pub fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { } pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { - let loaded = load_and_validate(workspace_root)?; + check_with_provenance(workspace_root, true) +} + +pub(crate) fn check_source_export(workspace_root: &Path) -> Result<(), String> { + check_with_provenance(workspace_root, false) +} + +pub(crate) fn active_group(workspace_root: &Path, group: &str) -> Result<Vec<String>, String> { + validate_identifier(group, "package group")?; + active_packages_matching(workspace_root, |package| { + package.groups.iter().any(|candidate| candidate == group) + }) +} + +pub(crate) fn active_packages(workspace_root: &Path) -> Result<Vec<String>, String> { + active_packages_matching(workspace_root, |_| true) +} + +fn active_packages_matching( + workspace_root: &Path, + predicate: impl Fn(&CatalogPackage) -> bool, +) -> Result<Vec<String>, String> { + let catalog = parse_file::<Catalog>(workspace_root, CATALOG_RELATIVE)?; + validate_catalog(&catalog)?; + let mut packages = catalog + .package + .iter() + .filter(|package| package.state == "active" && predicate(package)) + .map(|package| package.name.clone()) + .collect::<Vec<_>>(); + packages.sort_unstable(); + Ok(packages) +} + +fn check_with_provenance( + workspace_root: &Path, + require_git_provenance: bool, +) -> Result<(), String> { + let loaded = load_and_validate(workspace_root, require_git_provenance)?; for artifact in render_projections(&loaded.catalog, &loaded.catalog_digest) { let current = read_regular_file(workspace_root, artifact.relative)?; if current != artifact.contents { @@ -152,7 +201,7 @@ pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { } fn write(workspace_root: &Path) -> Result<(), String> { - let loaded = load_and_validate(workspace_root)?; + let loaded = load_and_validate(workspace_root, true)?; let artifacts = render_projections(&loaded.catalog, &loaded.catalog_digest); with_artifact_bundle_transaction(workspace_root, |transaction| transaction.write(artifacts)) } @@ -162,18 +211,25 @@ struct LoadedCatalog { catalog_digest: String, } -fn load_and_validate(workspace_root: &Path) -> Result<LoadedCatalog, String> { +fn load_and_validate( + workspace_root: &Path, + require_git_provenance: bool, +) -> Result<LoadedCatalog, String> { let catalog_bytes = read_regular_file(workspace_root, CATALOG_RELATIVE)?; let catalog = parse_toml::<Catalog>(CATALOG_RELATIVE, &catalog_bytes)?; let release = parse_file::<ReleaseV2>(workspace_root, RELEASE_RELATIVE)?; let consolidation = parse_file::<ConsolidationV1>(workspace_root, CONSOLIDATION_RELATIVE)?; + let coverage = parse_file::<CoveragePolicy>(workspace_root, COVERAGE_RELATIVE)?; validate_catalog(&catalog)?; + validate_coverage_authority(&catalog, &coverage)?; validate_release(&release, &catalog, workspace_root)?; validate_consolidation(&consolidation)?; validate_workspace_manifest(&catalog, workspace_root)?; let metadata = cargo_metadata(workspace_root)?; validate_metadata(&catalog, &metadata, workspace_root)?; - validate_active_source_provenance(&catalog, workspace_root)?; + if require_git_provenance { + validate_active_source_provenance(&catalog, workspace_root)?; + } Ok(LoadedCatalog { catalog, catalog_digest: sha256(&catalog_bytes), @@ -342,6 +398,7 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> { } let required_groups = BTreeSet::from([ "boundaries", + "coverage_required", "mobile", "portable", "preview", @@ -405,6 +462,37 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> { Ok(()) } +fn validate_coverage_authority(catalog: &Catalog, coverage: &CoveragePolicy) -> Result<(), String> { + let catalog_required = catalog + .package + .iter() + .filter(|package| { + package.state == "active" + && package + .groups + .iter() + .any(|group| group == "coverage_required") + }) + .map(|package| package.name.as_str()) + .collect::<BTreeSet<_>>(); + let policy_required = coverage + .required + .crates + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + if policy_required.len() != coverage.required.crates.len() { + return Err("coverage policy contains duplicate required packages".to_owned()); + } + if catalog_required != policy_required { + return Err( + "coverage required packages must exactly match the catalog coverage_required group" + .to_owned(), + ); + } + Ok(()) +} + fn validate_release( release: &ReleaseV2, catalog: &Catalog, @@ -548,6 +636,32 @@ fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Resu "Cargo workspace members must exactly match explicit active catalog paths".to_owned(), ); } + let default_members = workspace + .get("default-members") + .and_then(toml::Value::as_array) + .ok_or_else(|| "Cargo.toml lacks explicit default members".to_owned())? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| "default member must be a string".to_owned()) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let expected_defaults = catalog + .package + .iter() + .filter(|package| { + package.state == "active" + && (package.groups.iter().any(|group| group == "portable") + || package.name == "xtask") + }) + .map(|package| package.path.as_str()) + .collect::<BTreeSet<_>>(); + if default_members != expected_defaults { + return Err( + "Cargo default members must match active portable packages plus xtask".to_owned(), + ); + } if workspace.get("resolver").and_then(toml::Value::as_str) != Some("3") { return Err("Cargo workspace resolver must remain 3".to_owned()); } @@ -722,6 +836,8 @@ fn validate_active_source_provenance( fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> { let mut groups = BTreeMap::<&str, Vec<&str>>::new(); + let mut active_groups = BTreeMap::<&str, Vec<&str>>::new(); + let mut reserved_groups = BTreeMap::<&str, Vec<&str>>::new(); let mut platforms = BTreeMap::<&str, Vec<&str>>::new(); let mut public = Vec::new(); let mut private = Vec::new(); @@ -729,6 +845,14 @@ fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> for package in &catalog.package { for group in &package.groups { groups.entry(group).or_default().push(&package.name); + if package.state == "active" { + active_groups.entry(group).or_default().push(&package.name); + } else { + reserved_groups + .entry(group) + .or_default() + .push(&package.name); + } } for platform in &package.platforms { platforms.entry(platform).or_default().push(&package.name); @@ -743,6 +867,8 @@ fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> } } sort_map_values(&mut groups); + sort_map_values(&mut active_groups); + sort_map_values(&mut reserved_groups); sort_map_values(&mut platforms); public.sort_unstable(); private.sort_unstable(); @@ -750,11 +876,11 @@ fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> vec![ GeneratedArtifact { relative: GROUPS_RELATIVE, - contents: render_map_projection( - "radroots.workspace.package-groups.v1", + contents: render_group_projection( digest, - "group", &groups, + &active_groups, + &reserved_groups, ) .into_bytes(), }, @@ -781,6 +907,26 @@ fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> ] } +fn render_group_projection( + digest: &str, + groups: &BTreeMap<&str, Vec<&str>>, + active: &BTreeMap<&str, Vec<&str>>, + reserved: &BTreeMap<&str, Vec<&str>>, +) -> String { + let mut output = format!( + "schema = \"radroots.workspace.package-groups.v1\"\ncatalog_sha256 = \"{digest}\"\n" + ); + for (id, packages) in groups { + output.push_str(&format!( + "\n[[group]]\nid = \"{id}\"\npackages = {}\nactive_packages = {}\nreserved_packages = {}\n", + toml_array(packages), + toml_array(active.get(id).map(Vec::as_slice).unwrap_or_default()), + toml_array(reserved.get(id).map(Vec::as_slice).unwrap_or_default()), + )); + } + output +} + fn render_map_projection( schema: &str, digest: &str, diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -1108,6 +1108,7 @@ fn read_required_crates(path: &Path) -> Result<Vec<String>, String> { read_coverage_policy(path)?.required_crates() } +#[cfg(test)] fn read_workspace_crates(workspace_root: &Path) -> Result<Vec<String>, String> { let packages = read_workspace_packages(workspace_root)?; Ok(packages.into_iter().map(|(name, _)| name).collect()) @@ -2049,12 +2050,16 @@ fn read_gate_report(path: &Path) -> Result<CoverageGateReport, String> { fn list_required_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { let required_path = coverage_policy_path(root); - let crates = read_required_crates(&required_path)?; + let policy_crates = read_required_crates(&required_path)?; + let crates = crate::catalog::active_group(root, "coverage_required")?; + if crates.iter().collect::<BTreeSet<_>>() != policy_crates.iter().collect::<BTreeSet<_>>() { + return Err("coverage policy required crates drifted from the catalog".to_owned()); + } write_crate_names_output(writer, crates, "required crates") } fn list_workspace_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { - let crates = read_workspace_crates(root)?; + let crates = crate::catalog::active_packages(root)?; write_crate_names_output(writer, crates, "workspace crates") } @@ -5528,7 +5533,7 @@ test_threads = 0 let workspace_err = list_workspace_crates_with_root(&root, &mut output) .expect_err("missing workspace manifest should fail"); - assert!(workspace_err.contains("failed to read")); + assert!(workspace_err.contains("inspect artifact path")); fs::remove_dir_all(root).expect("remove list helper root"); } diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -12,6 +12,8 @@ mod api_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod architecture; #[cfg_attr(coverage_nightly, coverage(off))] +mod build_control; +#[cfg_attr(coverage_nightly, coverage(off))] mod catalog; #[cfg_attr(coverage_nightly, coverage(off))] mod consolidation; @@ -36,16 +38,228 @@ mod supply_chain_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod target_qualification; +use clap::{Parser, Subcommand, ValueEnum}; use std::env; use std::path::{Path, PathBuf}; use std::process::ExitCode; +#[derive(Debug, Parser)] +#[command(name = "xtask", disable_help_subcommand = true)] +struct Cli { + #[command(subcommand)] + command: XtaskCommand, +} + +#[derive(Debug, Subcommand)] +enum XtaskCommand { + Architecture, + ArchitectureCi, + ArchitectureSourceExportCi, + CheckApiBoundaries, + CheckDependencyBoundaries, + Check { + #[arg(long)] + group: String, + #[arg(long, value_enum, default_value_t = GroupOperation::Check)] + operation: GroupOperation, + #[arg(long)] + execute: bool, + #[arg(long)] + include_reserved: bool, + }, + Catalog { + #[command(subcommand)] + command: CatalogCommand, + }, + #[command(trailing_var_arg = true)] + Contract { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(trailing_var_arg = true)] + Consolidation { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(trailing_var_arg = true)] + Coverage { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(name = "dto-roots", trailing_var_arg = true)] + DtoRoots { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(trailing_var_arg = true)] + Generate { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(trailing_var_arg = true)] + Hygiene { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + #[command(trailing_var_arg = true)] + Release { + #[arg(allow_hyphen_values = true)] + args: Vec<String>, + }, + SourceLock { + #[arg(long)] + consumer_root: PathBuf, + }, + Source { + #[command(subcommand)] + command: SourceCommand, + }, + Artifact { + #[arg(long, value_enum)] + product: ArtifactProduct, + #[arg(long, value_enum)] + target: ArtifactTarget, + #[arg(long, value_enum)] + language: ArtifactLanguage, + #[arg(long, value_enum)] + mode: ArtifactMode, + #[arg(long)] + consumer_root: PathBuf, + #[arg(long)] + source_root: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long)] + source_date_epoch: u64, + #[arg(long)] + builder_id: String, + #[arg(long, value_delimiter = ',')] + features: Vec<String>, + }, +} + +#[derive(Clone, Copy, Debug, Subcommand)] +enum CatalogCommand { + Check, + Write, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum GroupOperation { + Check, + Test, + Clippy, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ArtifactMode { + Check, + Write, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum SourceMode { + Prefetch, + Offline, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ArtifactProduct { + Sdk, + Mobile, + Studio, +} + +impl ArtifactProduct { + fn as_str(self) -> &'static str { + match self { + Self::Sdk => "sdk", + Self::Mobile => "mobile", + Self::Studio => "studio", + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ArtifactTarget { + Typescript, + Wasm, + Ffi, + Ios, + Android, + Linux, + Macos, + Windows, +} + +impl ArtifactTarget { + fn as_str(self) -> &'static str { + match self { + Self::Typescript => "typescript", + Self::Wasm => "wasm", + Self::Ffi => "ffi", + Self::Ios => "ios", + Self::Android => "android", + Self::Linux => "linux", + Self::Macos => "macos", + Self::Windows => "windows", + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ArtifactLanguage { + Typescript, + Swift, + Kotlin, + Javascript, +} + +impl ArtifactLanguage { + fn as_str(self) -> &'static str { + match self { + Self::Typescript => "typescript", + Self::Swift => "swift", + Self::Kotlin => "kotlin", + Self::Javascript => "javascript", + } + } +} + +#[derive(Debug, Subcommand)] +enum SourceCommand { + Materialize { + #[arg(long)] + consumer_root: PathBuf, + #[arg(long)] + cache_root: PathBuf, + #[arg(long, value_enum)] + mode: SourceMode, + }, + ArchiveVerify { + #[arg(long)] + archive: PathBuf, + #[arg(long)] + sha256: String, + }, + ArchiveCreate { + #[arg(long)] + source_root: PathBuf, + #[arg(long)] + revision: String, + #[arg(long)] + output: PathBuf, + }, +} + fn usage() { eprintln!("usage:"); eprintln!(" cargo xtask architecture"); eprintln!(" cargo xtask architecture-ci"); + eprintln!(" cargo xtask architecture-source-export-ci"); eprintln!(" cargo xtask check-api-boundaries"); eprintln!(" cargo xtask check-dependency-boundaries"); + eprintln!(" cargo xtask check --group <group> [--operation check|test|clippy] [--execute]"); eprintln!(" cargo xtask catalog check|write"); eprintln!(" cargo xtask contract validate"); eprintln!(" cargo xtask contract event-contract-registry-v7 [--write]"); @@ -76,6 +290,17 @@ fn usage() { " cargo xtask coverage refresh-summary [--reports-root <dir>] [--out <file>] [--status-out <file>]" ); eprintln!(" cargo xtask hygiene forbidden-identifiers"); + eprintln!(" cargo xtask source-lock --consumer-root <absolute-directory>"); + eprintln!( + " cargo xtask source materialize --consumer-root <absolute-directory> --cache-root <absolute-directory> --mode <prefetch|offline>" + ); + eprintln!(" cargo xtask source archive-verify --archive <bundle> --sha256 <digest>"); + eprintln!( + " cargo xtask source archive-create --source-root <absolute-directory> --revision <full-sha> --output <absolute-bundle>" + ); + eprintln!( + " cargo xtask artifact --product <sdk|mobile|studio> --target <target> --language <language> --mode <check|write> --consumer-root <absolute-directory> --source-root <absolute-directory> --output <relative-path> --source-date-epoch <seconds> --builder-id <id>" + ); } fn workspace_root_with_override(override_root: Option<&str>) -> PathBuf { @@ -127,6 +352,10 @@ fn release_preflight() -> Result<(), String> { } fn release_preflight_at(root: &Path) -> Result<(), String> { + catalog::check(root)?; + for group in ["public_native", "preview", "tools"] { + build_control::group_plan(root, group, build_control::Operation::Check, false)?; + } dto_roots::check(root)?; generate::protocol::check(root)?; contract::validate_artifact_contracts(root)?; @@ -172,28 +401,112 @@ fn run_contract(args: &[String]) -> Result<(), String> { } fn run(args: &[String]) -> Result<(), String> { - match args.first().map(String::as_str) { - Some("architecture") if args.len() == 1 => architecture::validate(&workspace_root()), - Some("architecture-ci") if args.len() == 1 => { + let cli = Cli::try_parse_from(std::iter::once("xtask").chain(args.iter().map(String::as_str))) + .map_err(|error| error.to_string())?; + match cli.command { + XtaskCommand::Architecture => architecture::validate(&workspace_root()), + XtaskCommand::ArchitectureCi => { catalog::check(&workspace_root())?; architecture::validate_ci(&workspace_root())?; validate_contract() } - Some("check-api-boundaries") if args.len() == 1 => { + XtaskCommand::ArchitectureSourceExportCi => { + catalog::check_source_export(&workspace_root())?; + architecture::validate_ci(&workspace_root())?; + validate_contract() + } + XtaskCommand::CheckApiBoundaries => { architecture::validate_api_boundaries(&workspace_root()) } - Some("check-dependency-boundaries") if args.len() == 1 => { + XtaskCommand::CheckDependencyBoundaries => { architecture::validate_dependency_boundaries(&workspace_root()) } - Some("catalog") => catalog::run(&args[1..], &workspace_root()), - Some("contract") => run_contract(&args[1..]), - Some("consolidation") => consolidation::run(&args[1..], &workspace_root()), - Some("coverage") => coverage::run(&args[1..]), - Some("dto-roots") => dto_roots::run(&args[1..], &workspace_root()), - Some("generate") => generate::run(&args[1..], &workspace_root()), - Some("hygiene") => hygiene::run(&args[1..], &workspace_root()), - Some("release") => run_release(&args[1..]), - _ => Err("unknown command".to_string()), + XtaskCommand::Check { + group, + operation, + execute, + include_reserved, + } => { + let operation = match operation { + GroupOperation::Check => build_control::Operation::Check, + GroupOperation::Test => build_control::Operation::Test, + GroupOperation::Clippy => build_control::Operation::Clippy, + }; + let root = workspace_root(); + let plan = build_control::group_plan(&root, &group, operation, include_reserved)?; + if execute { + build_control::execute_group_plan(&root, &plan) + } else { + build_control::print_plan(&plan); + Ok(()) + } + } + XtaskCommand::Catalog { command } => match command { + CatalogCommand::Check => catalog::run(&["check".to_owned()], &workspace_root()), + CatalogCommand::Write => catalog::run(&["write".to_owned()], &workspace_root()), + }, + XtaskCommand::Contract { args } => run_contract(&args), + XtaskCommand::Consolidation { args } => consolidation::run(&args, &workspace_root()), + XtaskCommand::Coverage { args } => coverage::run(&args), + XtaskCommand::DtoRoots { args } => dto_roots::run(&args, &workspace_root()), + XtaskCommand::Generate { args } => generate::run(&args, &workspace_root()), + XtaskCommand::Hygiene { args } => hygiene::run(&args, &workspace_root()), + XtaskCommand::Release { args } => run_release(&args), + XtaskCommand::SourceLock { consumer_root } => { + build_control::validate_consumer(&consumer_root).map(|_| ()) + } + XtaskCommand::Source { command } => match command { + SourceCommand::Materialize { + consumer_root, + cache_root, + mode, + } => build_control::materialize( + &consumer_root, + &cache_root, + matches!(mode, SourceMode::Offline), + ) + .map(|path| { + println!("{}", path.display()); + }), + SourceCommand::ArchiveVerify { archive, sha256 } => { + build_control::verify_source_archive(&archive, &sha256) + } + SourceCommand::ArchiveCreate { + source_root, + revision, + output, + } => build_control::create_source_archive(&source_root, &revision, &output).map( + |digest| { + println!("{digest}"); + }, + ), + }, + XtaskCommand::Artifact { + product, + target, + language, + mode, + consumer_root, + source_root, + output, + source_date_epoch, + builder_id, + features, + } => build_control::artifact( + product.as_str(), + target.as_str(), + language.as_str(), + match mode { + ArtifactMode::Check => build_control::Mode::Check, + ArtifactMode::Write => build_control::Mode::Write, + }, + &consumer_root, + &source_root, + &output, + source_date_epoch, + &builder_id, + &features, + ), } } @@ -263,6 +576,47 @@ mod tests { } #[test] + fn typed_build_control_cli_requires_explicit_modes_and_known_values() { + let source_args = [ + "xtask", + "source", + "materialize", + "--consumer-root", + "/tmp/consumer", + "--cache-root", + "/tmp/cache", + ]; + assert!(Cli::try_parse_from(source_args).is_err()); + assert!(Cli::try_parse_from(source_args.into_iter().chain(["--mode", "prefetch"])).is_ok()); + + assert!( + Cli::try_parse_from([ + "xtask", + "artifact", + "--product", + "unknown", + "--target", + "wasm", + "--language", + "javascript", + "--mode", + "check", + "--consumer-root", + "/tmp/consumer", + "--source-root", + "/tmp/source", + "--output", + "generated/manifest.json", + "--source-date-epoch", + "1", + "--builder-id", + "fixture", + ]) + .is_err() + ); + } + + #[test] fn run_release_and_dispatchers_cover_error_paths() { let unknown_release = run_release(&["unknown".to_string()]).expect_err("unknown release subcommand"); @@ -278,7 +632,7 @@ mod tests { .expect_err("invalid registry-v7 mode"); assert!(invalid_registry.contains("exactly --write")); let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); - assert!(unknown_root.contains("unknown command")); + assert!(unknown_root.contains("unrecognized subcommand")); run(&["architecture".to_string()]).expect("architecture ledger validates"); @@ -291,15 +645,15 @@ mod tests { let removed_sdk = run(&["sdk".to_string(), "validate".to_string()]) .expect_err("removed sdk command namespace"); - assert!(removed_sdk.contains("unknown command")); + assert!(removed_sdk.contains("unrecognized subcommand")); } #[test] - fn release_preflight_checks_dto_root_authority_first() { + fn release_preflight_checks_catalog_authority_first() { let workspace = tempfile::TempDir::new().expect("create empty workspace"); let error = release_preflight_at(workspace.path()) - .expect_err("missing DTO root authority must fail first"); - assert!(error.contains("DTO root authority")); + .expect_err("missing catalog authority must fail first"); + assert!(error.contains("inspect artifact path") && error.contains("contracts")); } #[test]