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}"); }