From 92edd1295a3c6b633196bc08a8f4153c74e797cf Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Mon, 20 Jul 2026 19:49:46 -0600 Subject: [PATCH] fix(rust): harden release-manifest validator contracts and package readers Bind the versioned manifest basename to the embedded strict SemVer release version, make schema-ID plus embedded digest the provider-neutral provenance authority, and require exact pinned Rust/Cargo evidence and ordered advisory exceptions in renderer and live validation. Restrict clean-tree payloads to ignored dist paths, reject empty selectors, expand privacy canaries, run the full suite from the Make gate, preserve observer dependency versions in the minimized lock delta, and harden tar and Debian package metadata parsing. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 144 ++++---- Makefile | 14 +- RELEASING.md | 13 +- contracts/README.md | 4 +- contracts/rust-release-manifest-import.json | 10 +- crates/rust-release-manifest/src/lib.rs | 338 +++++++++++++----- crates/rust-release-manifest/src/tests.rs | 288 ++++++++++++++- .../solstone-linux/src/unsafe_policy_tests.rs | 2 +- 8 files changed, 632 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6bf9ca..2963789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,13 +251,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 2.0.119", ] [[package]] @@ -350,9 +350,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.2" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" [[package]] name = "bytes" @@ -362,9 +362,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.3.0" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -420,9 +420,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.3" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -442,9 +442,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.3" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -715,9 +715,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -807,24 +807,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -833,9 +833,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -852,9 +852,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -863,21 +863,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1131,9 +1131,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.11.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1468,9 +1468,9 @@ dependencies = [ [[package]] name = "kstring" -version = "2.0.4" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" dependencies = [ "static_assertions", ] @@ -1495,9 +1495,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.187" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libflac-sys" @@ -2003,9 +2003,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2077,9 +2077,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2414,9 +2414,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2424,29 +2424,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2457,13 +2457,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 2.0.119", ] [[package]] @@ -2779,22 +2779,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 2.0.119", ] [[package]] @@ -2808,9 +2808,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -2878,9 +2878,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" dependencies = [ "bytes", "libc", @@ -2895,9 +2895,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3391,9 +3391,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -3783,9 +3783,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3798,9 +3798,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", "winnow", @@ -3923,9 +3923,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.1" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" dependencies = [ "endi", "enumflags2", @@ -3937,9 +3937,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/Makefile b/Makefile index ca741a5..3329ce5 100644 --- a/Makefile +++ b/Makefile @@ -94,10 +94,13 @@ check-observer-contract: rust-preflight check-rust-release-manifest: rust-preflight @echo "Rust release manifest schema: 1" @echo "Rust release manifest schema SHA-256: d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c" - @if [ -n "$(MANIFEST)" ] && [ -n "$(RELEASE_DIR)" ]; then echo "error: release manifest mode mismatch: expected one selector, actual two" >&2; exit 1; \ - elif [ -n "$(MANIFEST)" ]; then \ + @manifest_set=$(if $(filter environment%,$(origin MANIFEST)),1,$(if $(findstring command line,$(origin MANIFEST)),1,0)); \ + release_dir_set=$(if $(filter environment%,$(origin RELEASE_DIR)),1,$(if $(findstring command line,$(origin RELEASE_DIR)),1,0)); \ + if { [ "$$manifest_set" -eq 1 ] && [ -z "$(strip $(MANIFEST))" ]; } || { [ "$$release_dir_set" -eq 1 ] && [ -z "$(strip $(RELEASE_DIR))" ]; }; then echo "error: release manifest selector empty" >&2; exit 1; \ + elif [ "$$manifest_set" -eq 1 ] && [ "$$release_dir_set" -eq 1 ]; then echo "error: release manifest mode mismatch: expected one selector, actual two" >&2; exit 1; \ + elif [ "$$manifest_set" -eq 1 ]; then \ CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- validate --manifest "$(MANIFEST)"; \ - elif [ -n "$(RELEASE_DIR)" ]; then \ + elif [ "$$release_dir_set" -eq 1 ]; then \ CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- validate --release-dir "$(RELEASE_DIR)"; \ else \ inventory=$$(CARGO_NET_OFFLINE=true $(CARGO) test $(CARGO_LOCKED) -p rust-release-manifest tests::rust_release_manifest_conformance -- --list); \ @@ -107,9 +110,10 @@ check-rust-release-manifest: rust-preflight [ "$$actual" -eq 1 ] || { echo "error: release manifest test inventory mismatch: expected 1, actual $$actual" >&2; exit 1; }; \ output=$$(mktemp); \ trap 'rm -f "$$output"' EXIT; \ - CARGO_NET_OFFLINE=true $(CARGO) test $(CARGO_LOCKED) -p rust-release-manifest tests::rust_release_manifest_conformance -- --exact >"$$output" 2>&1 || { status=$$?; tail -50 "$$output"; exit $$status; }; \ + CARGO_NET_OFFLINE=true $(CARGO) test $(CARGO_LOCKED) -p rust-release-manifest >"$$output" 2>&1 || { status=$$?; tail -50 "$$output"; exit $$status; }; \ tail -50 "$$output"; \ - grep -Eq 'test result: ok\. 1 passed; 0 failed' "$$output" || { echo "error: release manifest named test did not execute" >&2; exit 1; }; \ + results=$$(grep '^test result:' "$$output" || true); \ + [ -n "$$results" ] && ! printf '%s\n' "$$results" | grep -Ev '^test result: ok\..*0 failed' >/dev/null && ! grep -F 'FAILED' "$$output" >/dev/null || { echo "error: release manifest test suite did not pass" >&2; exit 1; }; \ fi shellcheck: diff --git a/RELEASING.md b/RELEASING.md index 599e711..b470aff 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,7 +79,8 @@ unit and no desktop file. ### Render and validate the release manifest After all three product artifacts exist, provide explicit release evidence to -the repository-local renderer. It writes `rust-release-manifest.json` and +the repository-local renderer. It writes +`solstone-linux--linux-x86_64.rust-release-manifest.json` and `SHA256SUMS` without discovering tool versions or contacting a service. Both files list the tarball, Debian package, and RPM in POSIX-basename order. Checksum rows use lowercase SHA-256, two spaces, the basename, and LF; neither @@ -94,7 +95,7 @@ make check-rust-release-manifest Verify one named manifest and its exact artifact bytes with: ```bash -make check-rust-release-manifest MANIFEST=dist/rust/rust-release-manifest.json +make check-rust-release-manifest MANIFEST=dist/rust/solstone-linux-1.0.0-linux-x86_64.rust-release-manifest.json ``` This mode is not candidate-readiness classification because it does not reject @@ -105,9 +106,11 @@ make check-rust-release-manifest RELEASE_DIR=dist/rust ``` Candidate classification requires exactly five regular, non-symlink files: -the tarball, Debian package, RPM, `SHA256SUMS`, and manifest. The renderer and -validator are offline and provider-neutral; signing and publication remain -separate operator actions. +the tarball, Debian package, RPM, `SHA256SUMS`, and versioned manifest. Release +validation requires the repository to be clean except for the supplied, +git-ignored payload under `dist/`. Active advisory exceptions retain their +exact `deny.toml` file order. The renderer and validator are offline and +provider-neutral; signing and publication remain separate operator actions. ## 5. Blocking first-release FLAC validation diff --git a/contracts/README.md b/contracts/README.md index db25922..dea1a8b 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -15,6 +15,6 @@ Bundle versions, solstone-linux application releases, and observer wire-protocol ## Rust release manifest schema import -solstone-linux keeps a frozen, byte-exact copy of the Rust release manifest schema in `vendor/rust-release-manifest/`. The repository-local renderer and validator use that copy to produce and verify release evidence fully offline. +solstone-linux keeps a frozen, byte-exact copy of the Rust release manifest schema in `vendor/rust-release-manifest/`. Its public schema ID and embedded SHA-256 digest are the provider-neutral authority. The repository-local renderer and validator use that copy to produce and verify release evidence fully offline. -To adopt an authority revision, verify the schema identity, dialect, byte length, and SHA-256 before replacing the vendored file. Then update `rust-release-manifest-import.json` with the public authority revision and exact schema provenance. Do not reformat or regenerate the schema during import. Run `make check-rust-release-manifest` to verify the frozen schema, renderer, checksums, package metadata, and release-directory classifier offline. +To adopt an authority revision, verify the schema ID, dialect, byte length, and SHA-256 before replacing the vendored file. Then update `rust-release-manifest-import.json` with that exact provider-neutral schema provenance. Do not reformat or regenerate the schema during import. Run `make check-rust-release-manifest` to verify the frozen schema, renderer, checksums, package metadata, and release-directory classifier offline. diff --git a/contracts/rust-release-manifest-import.json b/contracts/rust-release-manifest-import.json index 730369f..0bbf6e4 100644 --- a/contracts/rust-release-manifest-import.json +++ b/contracts/rust-release-manifest-import.json @@ -1,10 +1,8 @@ { - "authority_repository": "https://github.com/solpbc/solstone-linux", - "authority_commit": "977ecc43588d93ba81b9e67327888543492c153a", - "schema_version": 1, - "schema_path": "rust-release-manifest.schema.json", + "schema_id": "https://solpbc.org/schemas/rust-release-manifest/v1.json", "schema_sha256": "d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c", - "vendored_root": "vendor/rust-release-manifest", "schema_dialect": "https://json-schema.org/draft/2020-12/schema", - "schema_id": "https://solpbc.org/schemas/rust-release-manifest/v1.json" + "schema_version": 1, + "schema_path": "rust-release-manifest.schema.json", + "vendored_root": "vendor/rust-release-manifest" } diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index a1a2fac..303ac0a 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -12,7 +12,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{BufReader, Cursor, Read}; -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::net::IpAddr; +use std::os::unix::fs::MetadataExt; use std::path::{Component, Path, PathBuf}; use std::process::Command; use tar::Archive; @@ -20,7 +21,6 @@ use xz2::read::XzDecoder; pub const SCHEMA_VERSION: u64 = 1; pub const SCHEMA_SHA256: &str = "d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c"; -pub const MANIFEST_NAME: &str = "rust-release-manifest.json"; pub const CHECKSUM_NAME: &str = "SHA256SUMS"; pub const PRODUCT: &str = "solstone-linux"; pub const TARGET_TRIPLE: &str = "x86_64-unknown-linux-gnu"; @@ -30,6 +30,10 @@ pub const MANIFEST_OK_MESSAGE: &str = pub const RELEASE_DIR_OK_MESSAGE: &str = "Release directory verified as a complete five-file candidate."; +pub fn manifest_name(version: &str) -> String { + format!("solstone-linux-{version}-linux-x86_64.rust-release-manifest.json") +} + const SCHEMA_BYTES: &[u8] = include_bytes!("../../../vendor/rust-release-manifest/rust-release-manifest.schema.json"); const TOOL_KEYS: [&str; 18] = [ @@ -146,7 +150,8 @@ struct PackageIdentity { pub fn render_manifest(evidence: Evidence, release_dir: &Path) -> Result { validate_evidence(&evidence)?; - let artifacts = artifact_paths(release_dir)? + validate_version(&evidence.version)?; + let artifacts = artifact_paths(release_dir, &evidence.version)? .into_iter() .map(|path| artifact(&path)) .collect::>>()?; @@ -206,6 +211,12 @@ fn verify_manifest(path: &Path, bind_live: bool) -> Result<()> { .parent() .ok_or_else(|| Error::new("manifest parent missing"))?; let manifest = validate_manifest_bytes(&fs::read(path).map_err(display_error)?)?; + let expected_name = manifest_name(&manifest.version); + if path.file_name().and_then(OsStr::to_str) != Some(expected_name.as_str()) { + return Err(Error::new(format!( + "manifest basename mismatch: expected {expected_name}" + ))); + } if bind_live { validate_live(&manifest, root)?; } @@ -232,24 +243,29 @@ fn classify_release(root: &Path, bind_live: bool) -> Result<()> { return Err(Error::new(format!("duplicate release path: {name}"))); } } - let manifests: Vec<_> = names + let manifests = names .iter() - .filter(|name| name.as_str() == MANIFEST_NAME) - .collect(); + .filter(|name| name.ends_with(".rust-release-manifest.json")) + .collect::>(); if manifests.len() != 1 || names.len() != 5 { return Err(Error::new(format!( "release inventory mismatch: expected 5 files, actual {}", names.len() ))); } - verify_manifest(&root.join(MANIFEST_NAME), bind_live) + let manifest_path = root.join(manifests[0]); + verify_manifest(&manifest_path, bind_live) } pub fn write_rendered(evidence: Evidence, release_dir: &Path) -> Result<()> { let manifest_text = render_manifest(evidence, release_dir)?; let manifest = validate_manifest_bytes(manifest_text.as_bytes())?; let sums = render_sha256sums(&manifest.artifacts)?; - fs::write(release_dir.join(MANIFEST_NAME), manifest_text).map_err(display_error)?; + fs::write( + release_dir.join(manifest_name(&manifest.version)), + manifest_text, + ) + .map_err(display_error)?; fs::write(release_dir.join(CHECKSUM_NAME), sums).map_err(display_error) } @@ -264,14 +280,22 @@ fn validate_evidence(evidence: &Evidence) -> Result<()> { { return Err(Error::new("release evidence identity mismatch")); } + if evidence.active_exceptions != ordered_exceptions()? { + return Err(Error::new("release evidence active_exceptions mismatch")); + } validate_native_tools(&evidence.native_tools)?; validate_timestamp(&evidence.dependency_policy.advisory_checked_at) } fn validate_manifest_policy(manifest: &Manifest) -> Result<()> { + validate_version(&manifest.version)?; validate_native_tools(&manifest.native_tools)?; validate_timestamp(&manifest.dependency_policy.advisory_checked_at)?; - validate_artifact_set(&manifest.artifacts) + validate_artifact_set(&manifest.artifacts)?; + for artifact in &manifest.artifacts { + artifact_kind(&artifact.path, Some(&manifest.version))?; + } + Ok(()) } fn validate_timestamp(value: &str) -> Result<()> { @@ -297,6 +321,9 @@ fn validate_native_tools(tools: &BTreeMap) -> Result<()> { exact_tool(tools, "cargo_generate_rpm", "0.21.0")?; exact_tool(tools, "manifest_validator", env!("CARGO_PKG_VERSION"))?; exact_tool(tools, "signing_mode", "unsigned")?; + let rust_pin = rust_pin()?; + exact_tool(tools, "ubuntu_rustc", &format!("rustc {rust_pin}"))?; + exact_tool(tools, "ubuntu_cargo", &format!("cargo {rust_pin}"))?; for key in ["ubuntu_image_digest", "fedora_image_digest"] { let value = &tools[key]; if !is_sha256(value) { @@ -310,6 +337,8 @@ fn validate_native_tools(tools: &BTreeMap) -> Result<()> { | "cargo_generate_rpm" | "manifest_validator" | "signing_mode" + | "ubuntu_rustc" + | "ubuntu_cargo" | "ubuntu_image_digest" | "fedora_image_digest" ) { @@ -322,8 +351,7 @@ fn validate_native_tools(tools: &BTreeMap) -> Result<()> { fn exact_tool(tools: &BTreeMap, key: &str, expected: &str) -> Result<()> { if tools[key] != expected { return Err(Error::new(format!( - "native tool {key} mismatch: expected {expected}, actual {}", - tools[key] + "native tool {key} mismatch: expected {expected}" ))); } Ok(()) @@ -351,8 +379,6 @@ fn validate_identity(key: &str, value: &str) -> Result<()> { let approved_prefixes: &[&str] = match key { "container_engine" => &["podman ", "docker "], "ubuntu_os" => &["Ubuntu "], - "ubuntu_rustc" => &["rustc "], - "ubuntu_cargo" => &["cargo "], "ubuntu_compiler" => &["gcc ", "cc "], "ubuntu_linker" => &["GNU ld ", "ld "], "ubuntu_glibc" => &["glibc "], @@ -363,6 +389,21 @@ fn validate_identity(key: &str, value: &str) -> Result<()> { "rpm" => &["RPM ", "rpm "], _ => &[], }; + let tokens = value.split_whitespace().collect::>(); + let private_identifier = tokens.iter().any(|token| { + token.parse::().is_ok() + || (token.len() >= 32 + && token.contains('-') + && token + .chars() + .all(|character| character.is_ascii_hexdigit() || character == '-')) + || (token.len() >= 12 && token.chars().all(|character| character.is_ascii_digit())) + || (token.len() >= 20 + && token.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '+' | '/' | '=' | '_' | '-') + })) + }); let bad = !approved_prefixes .iter() .any(|prefix| value.starts_with(prefix)) @@ -372,6 +413,7 @@ fn validate_identity(key: &str, value: &str) -> Result<()> { || value.contains(['$', '%', '@', '/', '\\']) || value.contains("://") || forbidden.iter().any(|word| lower.contains(word)) + || private_identifier || !value.chars().any(|character| character.is_ascii_digit()) || value.split_whitespace().count() > 6; if bad { @@ -404,7 +446,7 @@ fn validate_artifact_set(artifacts: &[Artifact]) -> Result<()> { return Err(Error::new("artifact ordering mismatch")); } previous = Some(&artifact.path); - kinds.insert(artifact_kind(&artifact.path)?); + kinds.insert(artifact_kind(&artifact.path, None)?); } if kinds != BTreeSet::from(["deb", "rpm", "tar"]) { return Err(Error::new("artifact type inventory mismatch")); @@ -412,19 +454,36 @@ fn validate_artifact_set(artifacts: &[Artifact]) -> Result<()> { Ok(()) } -fn artifact_kind(name: &str) -> Result<&'static str> { - if name.starts_with("solstone-linux-") && name.ends_with("-linux-x86_64.tar.gz") { - Ok("tar") - } else if name.starts_with("solstone-linux_") && name.ends_with("-1_amd64.deb") { - Ok("deb") - } else if name.starts_with("solstone-linux-") && name.ends_with("-1.x86_64.rpm") { - Ok("rpm") +fn artifact_kind(name: &str, expected_version: Option<&str>) -> Result<&'static str> { + let (kind, version) = if let Some(version) = name + .strip_prefix("solstone-linux-") + .and_then(|value| value.strip_suffix("-linux-x86_64.tar.gz")) + { + ("tar", version) + } else if let Some(version) = name + .strip_prefix("solstone-linux_") + .and_then(|value| value.strip_suffix("-1_amd64.deb")) + { + ("deb", version) + } else if let Some(version) = name + .strip_prefix("solstone-linux-") + .and_then(|value| value.strip_suffix("-1.x86_64.rpm")) + { + ("rpm", version) } else { - Err(Error::new(format!("artifact basename mismatch: {name}"))) + return Err(Error::new(format!("artifact basename mismatch: {name}"))); + }; + validate_version(version)?; + if expected_version.is_some_and(|expected| version != expected) { + return Err(Error::new(format!( + "artifact version mismatch: expected {}", + expected_version.unwrap() + ))); } + Ok(kind) } -fn artifact_paths(root: &Path) -> Result> { +fn artifact_paths(root: &Path, version: &str) -> Result> { require_directory(root, "release root")?; let mut paths = Vec::new(); for entry in fs::read_dir(root).map_err(display_error)? { @@ -433,7 +492,7 @@ fn artifact_paths(root: &Path) -> Result> { .file_name() .into_string() .map_err(|_| Error::new("artifact path is not UTF-8"))?; - if artifact_kind(&name).is_ok() { + if artifact_kind(&name, Some(version)).is_ok() { require_regular(&entry.path(), &name)?; paths.push(entry.path()); } @@ -479,6 +538,15 @@ fn verify_artifacts(manifest: &Manifest, root: &Path) -> Result<()> { } pub fn verify_checksums(manifest: &Manifest, root: &Path) -> Result<()> { + for expected in &manifest.artifacts { + let actual = artifact(&root.join(&expected.path))?; + if actual != *expected { + return Err(Error::new(format!( + "artifact checksum mismatch: {}", + expected.path + ))); + } + } let path = root.join(CHECKSUM_NAME); require_regular(&path, CHECKSUM_NAME)?; let actual = fs::read_to_string(path).map_err(display_error)?; @@ -492,7 +560,7 @@ pub fn verify_checksums(manifest: &Manifest, root: &Path) -> Result<()> { fn verify_package_identity(path: &Path, version: &str) -> Result<()> { let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); validate_version(version)?; - let expected = match artifact_kind(name)? { + let expected = match artifact_kind(name, Some(version))? { "tar" => PackageIdentity { name: PRODUCT.to_owned(), version: tar_version(path)?, @@ -523,10 +591,13 @@ fn tar_version(path: &Path) -> Result { for entry in archive.entries().map_err(display_error)? { let entry = entry.map_err(display_error)?; let path = entry.path().map_err(display_error)?; + let path_text = path + .to_str() + .ok_or_else(|| Error::new("tar path mismatch: expected UTF-8"))?; + portable_path(path_text)?; let root = path .components() - .next() - .and_then(|part| match part { + .find_map(|part| match part { Component::Normal(value) => value.to_str(), _ => None, }) @@ -547,18 +618,38 @@ fn deb_identity(path: &Path) -> Result { let file = File::open(path).map_err(display_error)?; let mut archive = ar::Archive::new(file); let mut control = None; + let mut marker_count = 0; + let mut control_count = 0; while let Some(entry) = archive.next_entry() { let mut entry = entry.map_err(display_error)?; let name = std::str::from_utf8(entry.header().identifier()) .map_err(display_error)? .trim_end_matches('/') .to_owned(); - if name.starts_with("control.tar.") { + if name == "debian-binary" { + marker_count += 1; + let mut marker = Vec::new(); + entry.read_to_end(&mut marker).map_err(display_error)?; + if marker != b"2.0\n" { + return Err(Error::new("deb format marker mismatch: expected 2.0")); + } + } else if name.starts_with("control.tar.") { + control_count += 1; let mut compressed = Vec::new(); entry.read_to_end(&mut compressed).map_err(display_error)?; control = Some(read_control_archive(&name, compressed)?); } } + if marker_count != 1 { + return Err(Error::new(format!( + "deb format marker count mismatch: expected 1, actual {marker_count}" + ))); + } + if control_count != 1 { + return Err(Error::new(format!( + "deb control archive count mismatch: expected 1, actual {control_count}" + ))); + } parse_deb_control(&control.ok_or_else(|| Error::new("deb control archive missing"))?) } @@ -573,24 +664,36 @@ fn read_control_archive(name: &str, bytes: Vec) -> Result { return Err(Error::new("deb control compression mismatch")); }; let mut archive = Archive::new(reader); + let mut control = None; + let mut control_count = 0; for entry in archive.entries().map_err(display_error)? { let mut entry = entry.map_err(display_error)?; let path = entry.path().map_err(display_error)?; if matches!(path.to_str(), Some("control" | "./control")) { + control_count += 1; let mut body = String::new(); entry.read_to_string(&mut body).map_err(display_error)?; - return Ok(body); + control = Some(body); } } - Err(Error::new("deb control metadata missing")) + if control_count != 1 { + return Err(Error::new(format!( + "deb control metadata count mismatch: expected 1, actual {control_count}" + ))); + } + control.ok_or_else(|| Error::new("deb control metadata missing")) } fn parse_deb_control(body: &str) -> Result { - let fields = body - .lines() - .filter_map(|line| line.split_once(":")) - .map(|(key, value)| (key, value.trim())) - .collect::>(); + let mut fields = BTreeMap::new(); + for line in body.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + if fields.insert(key, value.trim()).is_some() { + return Err(Error::new(format!("deb control field duplicate: {key}"))); + } + } let version = field(&fields, "Version")?; let (version, release) = version .rsplit_once('-') @@ -653,43 +756,82 @@ fn validate_live(manifest: &Manifest, payload_root: &Path) -> Result<()> { .lines() .find_map(|line| line.strip_prefix("CARGO_DENY_VERSION := ")) .ok_or_else(|| Error::new("cargo-deny version authority missing"))?; + let active_exceptions = ordered_exceptions()?; + let checks = [ + (manifest.product == product, "product"), + (manifest.version == version, "version"), + (manifest.source_commit == commit, "source_commit"), + (!manifest.source_dirty, "source_dirty"), + ( + manifest.cargo_lock_sha256 == lock_digest, + "cargo_lock_sha256", + ), + ( + manifest.dependency_policy.cargo_deny_version == cargo_deny_version, + "cargo_deny_version", + ), + ( + manifest.dependency_policy.deterministic_gate == "pass", + "deterministic_gate", + ), + ( + manifest.active_exceptions == active_exceptions, + "active_exceptions", + ), + ( + matches!(&manifest.target, TargetEvidence::Compiled { triple, profile, features } + if triple == TARGET_TRIPLE && profile == "release" && features.is_empty()), + "target", + ), + ]; + if let Some((_, field)) = checks.into_iter().find(|(matches, _)| !matches) { + return Err(Error::new(format!( + "live release evidence mismatch: {field}" + ))); + } + require_clean_tree(&root, payload_root) +} + +fn ordered_exceptions() -> Result> { + let root = workspace_root()?; let deny: toml::Value = toml::from_str(&fs::read_to_string(root.join("deny.toml")).map_err(display_error)?) .map_err(display_error)?; - let active_exceptions = deny["advisories"]["ignore"] + deny["advisories"]["ignore"] .as_array() .ok_or_else(|| Error::new("advisory exception authority missing"))? .iter() .map(|entry| { entry["id"] .as_str() + .map(str::to_owned) .ok_or_else(|| Error::new("advisory exception id missing")) }) - .collect::>>()?; - if manifest.product != product - || manifest.version != version - || manifest.source_commit != commit - || manifest.source_dirty - || manifest.cargo_lock_sha256 != lock_digest - || manifest.dependency_policy.cargo_deny_version != cargo_deny_version - || manifest.dependency_policy.deterministic_gate != "pass" - || manifest - .active_exceptions - .iter() - .map(String::as_str) - .collect::>() - != active_exceptions - || !matches!(&manifest.target, TargetEvidence::Compiled { triple, profile, features } - if triple == TARGET_TRIPLE && profile == "release" && features.is_empty()) - { - return Err(Error::new("live release evidence mismatch")); - } - require_clean_tree(&root, payload_root) + .collect() } fn require_clean_tree(root: &Path, payload_root: &Path) -> Result<()> { + let root = root.canonicalize().map_err(display_error)?; + let payload_root = payload_root.canonicalize().map_err(display_error)?; + let dist = root.join("dist"); + if payload_root != dist && !payload_root.starts_with(&dist) { + return Err(Error::new( + "release payload root mismatch: expected repository dist path", + )); + } + let ignored = Command::new("git") + .args(["check-ignore", "--quiet", "--"]) + .arg(&payload_root) + .current_dir(&root) + .status() + .map_err(display_error)?; + if !ignored.success() { + return Err(Error::new( + "release payload ignore mismatch: expected git-ignored dist path", + )); + } let status = command( - root, + &root, &[ "git", "status", @@ -698,18 +840,8 @@ fn require_clean_tree(root: &Path, payload_root: &Path) -> Result<()> { "--ignored=no", ], )?; - if status.is_empty() { - return Ok(()); - } - let allowed = payload_root - .canonicalize() - .unwrap_or_else(|_| payload_root.to_owned()); - for line in status.lines() { - let relative = line.get(3..).unwrap_or_default().trim_matches('"'); - let path = root.join(relative); - if !path.starts_with(&allowed) { - return Err(Error::new("source dirty mismatch: expected clean tree")); - } + if !status.is_empty() { + return Err(Error::new("source dirty mismatch: expected clean tree")); } Ok(()) } @@ -721,6 +853,18 @@ fn workspace_root() -> Result { .map_err(display_error) } +fn rust_pin() -> Result { + let root = workspace_root()?; + let toolchain: toml::Value = toml::from_str( + &fs::read_to_string(root.join("rust-toolchain.toml")).map_err(display_error)?, + ) + .map_err(display_error)?; + toolchain["toolchain"]["channel"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| Error::new("Rust pin authority missing")) +} + fn command(root: &Path, args: &[&str]) -> Result { let output = Command::new(args[0]) .args(&args[1..]) @@ -806,29 +950,59 @@ fn require_directory(path: &Path, label: &str) -> Result<()> { "{label} mismatch: expected no-follow directory" ))); } - if metadata.file_type().is_socket() - || metadata.file_type().is_fifo() - || metadata.file_type().is_block_device() - || metadata.file_type().is_char_device() - { - return Err(Error::new(format!("{label} special file mismatch"))); - } Ok(()) } fn validate_version(value: &str) -> Result<()> { - let valid = !value.is_empty() - && !value.starts_with('-') - && value.trim() == value - && value.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '-') + if value.is_empty() + || value.trim() != value + || value.chars().any(char::is_control) + || value.matches('+').count() > 1 + { + return Err(Error::new("version mismatch")); + } + let (without_build, build) = value + .split_once('+') + .map_or((value, None), |(core, build)| (core, Some(build))); + if build.is_some_and(|identifiers| !valid_semver_identifiers(identifiers, false)) { + return Err(Error::new("version mismatch")); + } + let (core, prerelease) = without_build + .split_once('-') + .map_or((without_build, None), |(core, prerelease)| { + (core, Some(prerelease)) }); - if !valid { + if prerelease.is_some_and(|identifiers| !valid_semver_identifiers(identifiers, true)) { + return Err(Error::new("version mismatch")); + } + let parts = core.split('.').collect::>(); + if parts.len() != 3 + || parts.iter().any(|part| { + part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + || part.parse::().is_err() + }) + { return Err(Error::new("version mismatch")); } Ok(()) } +fn valid_semver_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool { + !value.is_empty() + && value.split('.').all(|identifier| { + !identifier.is_empty() + && identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && (!reject_numeric_leading_zero + || !identifier.bytes().all(|byte| byte.is_ascii_digit()) + || identifier.len() == 1 + || !identifier.starts_with('0')) + }) +} + fn is_sha256(value: &str) -> bool { value.len() == 64 && value diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 5d99222..1aa36bc 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -87,6 +87,25 @@ fn tarball(root: &Path, version: &str) -> PathBuf { path } +fn raw_tarball(root: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { + let path = root.join(name); + let encoder = GzEncoder::new(File::create(&path).unwrap(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + for (entry_path, body) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + let name_bytes = entry_path.as_bytes(); + assert!(name_bytes.len() < 100); + header.as_mut_bytes()[..100].fill(0); + header.as_mut_bytes()[..name_bytes.len()].copy_from_slice(name_bytes); + header.set_cksum(); + builder.append(&header, *body).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap(); + path +} + fn control_tar(version: &str) -> Vec { let mut archive = tar::Builder::new(Vec::new()); let body = format!("Package: solstone-linux\nVersion: {version}-1\nArchitecture: amd64\n"); @@ -100,6 +119,36 @@ fn control_tar(version: &str) -> Vec { archive.into_inner().unwrap() } +fn control_tar_bodies(bodies: &[String]) -> Vec { + let mut archive = tar::Builder::new(Vec::new()); + for body in bodies { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive + .append_data(&mut header, "./control", body.as_bytes()) + .unwrap(); + } + archive.into_inner().unwrap() +} + +fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +fn deb_members(root: &Path, name: &str, members: &[(&str, &[u8])]) -> PathBuf { + let path = root.join(name); + let mut archive = ar::Builder::new(File::create(&path).unwrap()); + for (member_name, bytes) in members { + let header = ar::Header::new(member_name.as_bytes().to_vec(), bytes.len() as u64); + archive.append(&header, *bytes).unwrap(); + } + path +} + fn deb(root: &Path, version: &str) -> PathBuf { let path = root.join(format!("solstone-linux_{version}-1_amd64.deb")); let mut archive = ar::Builder::new(File::create(&path).unwrap()); @@ -161,6 +210,18 @@ fn rust_release_manifest_conformance() { descriptor["schema_id"], "https://solpbc.org/schemas/rust-release-manifest/v1.json" ); + assert_eq!( + descriptor["schema_dialect"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!( + descriptor["schema_path"], + "rust-release-manifest.schema.json" + ); + assert_eq!(descriptor["vendored_root"], "vendor/rust-release-manifest"); + assert!(descriptor.get("authority_repository").is_none()); + assert!(descriptor.get("authority_commit").is_none()); + assert_eq!(descriptor.as_object().unwrap().len(), 6); let temp = release_fixture(); let first = render_manifest(evidence(), temp.path()).unwrap(); let second = render_manifest(evidence(), temp.path()).unwrap(); @@ -212,6 +273,8 @@ fn rust_release_manifest_conformance() { } for canary in [ "$VERSION", + "${VERSION}", + "%VERSION%", "tool 1.0 token=abc", "tool 1.0 builder.internal", "tool 1.0 /usr/bin/tool", @@ -231,6 +294,47 @@ fn rust_release_manifest_conformance() { assert!(validate_manifest_bytes(serde_json::to_string(&value).unwrap().as_bytes()).is_err()); } +#[test] +fn named_manifest_requires_versioned_basename() { + let temp = release_fixture(); + let version = evidence().version; + write_rendered(evidence(), temp.path()).unwrap(); + let correct = temp.path().join(manifest_name(&version)); + verify_manifest(&correct, false).unwrap(); + + for wrong_name in [ + "rust-release-manifest.json".to_owned(), + manifest_name("9.9.9"), + ] { + let wrong = temp.path().join(wrong_name); + fs::copy(&correct, &wrong).unwrap(); + let error = verify_manifest(&wrong, false).unwrap_err(); + assert!(error.to_string().contains("manifest basename mismatch")); + } +} + +#[test] +fn renderer_requires_authoritative_ordered_exceptions() { + let temp = release_fixture(); + let mut cases = Vec::new(); + + let mut dropped = evidence(); + dropped.active_exceptions.pop(); + cases.push(dropped); + + let mut added = evidence(); + added.active_exceptions.push("RUSTSEC-2026-9999".to_owned()); + cases.push(added); + + let mut reordered = evidence(); + reordered.active_exceptions.reverse(); + cases.push(reordered); + + for candidate in cases { + assert!(render_manifest(candidate, temp.path()).is_err()); + } +} + #[test] fn package_readers_reject_malformed_and_stale_bytes() { let temp = release_fixture(); @@ -245,6 +349,75 @@ fn package_readers_reject_malformed_and_stale_bytes() { let bad_rpm = temp.path().join("solstone-linux-9.9.9-1.x86_64.rpm"); fs::write(&bad_rpm, b"truncated").unwrap(); assert!(rpm_identity(&bad_rpm).is_err()); + + for (name, entries) in [ + ("traversal.tar.gz", vec![("../evil", &b"x"[..])]), + ("absolute.tar.gz", vec![("/etc/passwd", &b"x"[..])]), + ( + "multiple-roots.tar.gz", + vec![("one/file", &b"x"[..]), ("two/file", &b"y"[..])], + ), + ] { + assert!(tar_version(&raw_tarball(temp.path(), name, &entries)).is_err()); + } + + let body = format!("Package: solstone-linux\nVersion: {version}-1\nArchitecture: amd64\n"); + let control = gzip(&control_tar_bodies(std::slice::from_ref(&body))); + let marker = b"2.0\n"; + assert!( + deb_identity(&deb_members( + temp.path(), + "missing-marker.deb", + &[("control.tar.gz", &control)], + )) + .is_err() + ); + assert!( + deb_identity(&deb_members( + temp.path(), + "wrong-marker.deb", + &[("debian-binary", b"2.1\n"), ("control.tar.gz", &control)], + )) + .is_err() + ); + assert!( + deb_identity(&deb_members( + temp.path(), + "duplicate-control-archive.deb", + &[ + ("debian-binary", marker), + ("control.tar.gz", &control), + ("control.tar.gz", &control), + ], + )) + .is_err() + ); + + let duplicate_entries = gzip(&control_tar_bodies(&[body.clone(), body.clone()])); + assert!( + deb_identity(&deb_members( + temp.path(), + "duplicate-control-entry.deb", + &[ + ("debian-binary", marker), + ("control.tar.gz", &duplicate_entries), + ], + )) + .is_err() + ); + let duplicate_field = format!("{body}Version: {version}-1\n"); + let duplicate_field = gzip(&control_tar_bodies(&[duplicate_field])); + assert!( + deb_identity(&deb_members( + temp.path(), + "duplicate-control-field.deb", + &[ + ("debian-binary", marker), + ("control.tar.gz", &duplicate_field), + ], + )) + .is_err() + ); } #[test] @@ -252,7 +425,8 @@ fn checksum_and_complete_inventory_mutations_fail() { let temp = release_fixture(); let text = render_manifest(evidence(), temp.path()).unwrap(); let manifest = validate_manifest_bytes(text.as_bytes()).unwrap(); - fs::write(temp.path().join(MANIFEST_NAME), text).unwrap(); + let manifest_path = temp.path().join(manifest_name(&manifest.version)); + fs::write(&manifest_path, text).unwrap(); fs::write( temp.path().join(CHECKSUM_NAME), render_sha256sums(&manifest.artifacts).unwrap(), @@ -263,6 +437,10 @@ fn checksum_and_complete_inventory_mutations_fail() { } verify_checksums(&manifest, temp.path()).unwrap(); classify_release(temp.path(), false).unwrap(); + let stale_manifest = temp.path().join(manifest_name("2.0.0")); + fs::rename(&manifest_path, &stale_manifest).unwrap(); + assert!(classify_release(temp.path(), false).is_err()); + fs::rename(stale_manifest, &manifest_path).unwrap(); let original = fs::read_to_string(temp.path().join(CHECKSUM_NAME)).unwrap(); let mut lines = original.lines().collect::>(); lines.swap(0, 1); @@ -358,6 +536,9 @@ fn live_semantic_drift_is_rejected_field_by_field() { let mut candidate = manifest.clone(); candidate.active_exceptions[0] = "RUSTSEC-2026-9998".into(); reject(&candidate); + let mut candidate = manifest.clone(); + candidate.active_exceptions.reverse(); + reject(&candidate); for target in [ TargetEvidence::Compiled { @@ -404,6 +585,8 @@ fn native_tool_exact_identity_and_digest_mutations_fail() { ("cargo_generate_rpm", "0.21.1"), ("manifest_validator", "9.9.9"), ("signing_mode", "signed"), + ("ubuntu_rustc", "rustc 1.97.2"), + ("ubuntu_cargo", "cargo 1.97.2"), ] { let mut value = original.clone(); value["native_tools"][key] = Value::String(wrong.into()); @@ -412,8 +595,6 @@ fn native_tool_exact_identity_and_digest_mutations_fail() { for key in [ "container_engine", "ubuntu_os", - "ubuntu_rustc", - "ubuntu_cargo", "ubuntu_compiler", "ubuntu_linker", "ubuntu_glibc", @@ -445,7 +626,7 @@ fn native_tool_exact_identity_and_digest_mutations_fail() { fn artifact_file_path_and_checksum_mutations_fail() { let (temp, manifest) = rendered_manifest(); fs::write( - temp.path().join(MANIFEST_NAME), + temp.path().join(manifest_name(&manifest.version)), serde_json::to_string_pretty(&manifest).unwrap() + "\n", ) .unwrap(); @@ -453,12 +634,11 @@ fn artifact_file_path_and_checksum_mutations_fail() { fs::write(temp.path().join(CHECKSUM_NAME), &sums).unwrap(); let artifact_path = temp.path().join(&manifest.artifacts[0].path); + let artifact_bytes = fs::read(&artifact_path).unwrap(); fs::write(&artifact_path, b"mutated after render").unwrap(); assert!(verify_artifacts(&manifest, temp.path()).is_err()); - assert!( - verify_checksums(&manifest, temp.path()).is_err() - || verify_artifacts(&manifest, temp.path()).is_err() - ); + assert!(verify_checksums(&manifest, temp.path()).is_err()); + fs::write(&artifact_path, artifact_bytes).unwrap(); let (missing, missing_manifest) = rendered_manifest(); fs::remove_file(missing.path().join(&missing_manifest.artifacts[0].path)).unwrap(); @@ -554,3 +734,95 @@ fn schema_target_commit_and_datetime_boundaries_are_enforced() { fn manifest_mode_success_message_disclaims_candidate_readiness() { assert!(MANIFEST_OK_MESSAGE.contains("NOT candidate-readiness classification")); } + +#[test] +fn strict_semver_and_canonical_artifact_names_are_enforced() { + for version in [ + "0.0.0", + "1.2.3", + "1.2.3-alpha.1", + "1.2.3+build.5", + "1.2.3-alpha+build", + ] { + validate_version(version).unwrap(); + artifact_kind( + &format!("solstone-linux-{version}-linux-x86_64.tar.gz"), + Some(version), + ) + .unwrap(); + } + for version in [ + "1", "1.2", "01.2.3", "1.02.3", "1.2.03", "1.2.3-01", "1.2.3-", "1.2.3+", " 1.2.3", + "-1.2.3", + ] { + assert!(validate_version(version).is_err(), "accepted {version}"); + } + assert!( + artifact_kind( + "prefix-solstone-linux-1.0.0-linux-x86_64.tar.gz", + Some("1.0.0") + ) + .is_err() + ); + assert!(artifact_kind("solstone-linux-2.0.0-linux-x86_64.tar.gz", Some("1.0.0")).is_err()); +} + +#[test] +fn privacy_canaries_reject_network_account_and_opaque_tokens() { + let (temp, _) = rendered_manifest(); + let original: Value = + serde_json::from_str(&render_manifest(evidence(), temp.path()).unwrap()).unwrap(); + for canary in [ + "podman 5.4.0 10.0.0.1", + "podman 5.4.0 2001:db8::1", + "podman 5.4.0 123e4567-e89b-12d3-a456-426614174000", + "podman 5.4.0 YWJjZGVmZ2hpamtsbW5vcHFyc3R1", + "podman 5.4.0 ${ENGINE_VERSION}", + "podman 5.4.0 %ENGINE_VERSION%", + ] { + let mut value = original.clone(); + value["native_tools"]["container_engine"] = Value::String(canary.into()); + assert!( + validate_manifest_bytes(serde_json::to_string(&value).unwrap().as_bytes()).is_err(), + "accepted {canary}" + ); + } +} + +#[test] +fn clean_tree_policy_distinguishes_source_from_ignored_outputs() { + let repo = tempfile::tempdir().unwrap(); + command(repo.path(), &["git", "init"]).unwrap(); + command( + repo.path(), + &["git", "config", "user.email", "fixture@example.com"], + ) + .unwrap(); + command(repo.path(), &["git", "config", "user.name", "Fixture User"]).unwrap(); + fs::write(repo.path().join(".gitignore"), "dist/\n").unwrap(); + fs::write(repo.path().join("source.txt"), "committed\n").unwrap(); + command(repo.path(), &["git", "add", ".gitignore", "source.txt"]).unwrap(); + command(repo.path(), &["git", "commit", "-m", "fixture"]).unwrap(); + + let payload = repo.path().join("dist/rust"); + fs::create_dir_all(&payload).unwrap(); + fs::write(payload.join("artifact"), "ignored\n").unwrap(); + require_clean_tree(repo.path(), &payload).unwrap(); + + fs::write(repo.path().join("source.txt"), "modified\n").unwrap(); + assert!(require_clean_tree(repo.path(), &payload).is_err()); + fs::write(repo.path().join("source.txt"), "committed\n").unwrap(); + + let untracked = repo.path().join("untracked.txt"); + fs::write(&untracked, "untracked\n").unwrap(); + assert!(require_clean_tree(repo.path(), &payload).is_err()); + fs::remove_file(untracked).unwrap(); + + let outside = tempfile::tempdir().unwrap(); + assert!(require_clean_tree(repo.path(), outside.path()).is_err()); + + fs::write(repo.path().join(".gitignore"), "other/\n").unwrap(); + command(repo.path(), &["git", "add", ".gitignore"]).unwrap(); + command(repo.path(), &["git", "commit", "-m", "change ignores"]).unwrap(); + assert!(require_clean_tree(repo.path(), &payload).is_err()); +} diff --git a/crates/solstone-linux/src/unsafe_policy_tests.rs b/crates/solstone-linux/src/unsafe_policy_tests.rs index 273c04d..efd699f 100644 --- a/crates/solstone-linux/src/unsafe_policy_tests.rs +++ b/crates/solstone-linux/src/unsafe_policy_tests.rs @@ -1367,7 +1367,7 @@ fn repository_unsafe_inventory_matches_reviewed_seams() { assert!(inventory.files_inspected >= 50, "{inventory:#?}"); assert!(inventory.nested_src_files >= 10, "{inventory:#?}"); assert!(inventory.build_scripts >= 1, "{inventory:#?}"); - assert_eq!(inventory.members, 1); + assert_eq!(inventory.members, 2); if let Err(error) = reviewed_seams_error(&inventory) { panic!("{error}"); } -- 2.51.2