Add shift-left quality gates: cognitive complexity, coverage, dependency fix
Adds two new gates to the existing format/lint/test/audit pipeline (Makefile.toml `cargo make ci`, .forgejo/workflows/ci.yml): - Cognitive complexity via clippy's nursery cognitive_complexity lint (clippy.toml, threshold 15), scoped to --bins so test code's naturally higher branch count doesn't get gated. Went with this over the closest real cyclomatic-complexity tool (rust-code-analysis-cli) because that crate hasn't shipped a release since Jan 2023. - Test coverage via cargo-llvm-cov, chosen over cargo-tarpaulin for friendlier behavior in containerized/dind CI (no ptrace). Report-only for now (no --fail-under-lines) since a real threshold needs real usage data first — see below. main.rs is excluded: it's orchestration glue exercised by the real end-to-end `cargo run`, not unit tests. Getting both gates running required writing pkgwatch's first tests (previously zero). To make the GitHub-facing modules unit-testable without hitting real github.com/api.github.com, added `GithubEndpoints` (src/github.rs) so checker/fetcher/verifier take injectable base URLs, and added mockito + tempfile as dev-dependencies. Result: 27 tests, 94% region / 96% line coverage excluding main.rs. Also: cargo audit (now wired into `cargo make ci`) immediately caught a real, currently-open advisory (RUSTSEC-2026-0285, published days ago) in the transitive rustls dependency — bumped 0.23.44 -> 0.23.45 to clear it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7f4bb4cccf
commit
13a1381bdf
12 changed files with 946 additions and 19 deletions
|
|
@ -39,6 +39,12 @@ jobs:
|
|||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Cognitive complexity (clippy nursery lint — see clippy.toml for why
|
||||
# not literal cyclomatic complexity), bin target only so test code's
|
||||
# naturally higher complexity isn't gated on this.
|
||||
- name: Complexity
|
||||
run: cargo clippy --bins -- -D warnings -W clippy::cognitive_complexity
|
||||
|
||||
- name: Check release profile compiles
|
||||
run: cargo check --release
|
||||
|
||||
|
|
@ -70,6 +76,46 @@ jobs:
|
|||
- name: Test
|
||||
run: cargo test
|
||||
|
||||
coverage:
|
||||
needs: build
|
||||
runs-on: rust-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Cache sccache compilation objects
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.cache/sccache
|
||||
key: sccache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
sccache-${{ runner.os }}-
|
||||
|
||||
# Not baked into the rust-ci image (see docker/rust-ci in
|
||||
# infrastructure) — install fresh each run rather than bumping the
|
||||
# shared image just for this one project.
|
||||
- name: Install cargo-llvm-cov
|
||||
run: |
|
||||
rustup component add llvm-tools-preview
|
||||
command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov --locked
|
||||
|
||||
# Reports coverage only — no --fail-under-lines yet. main.rs is
|
||||
# excluded: thin orchestration glue exercised by the real end-to-end
|
||||
# `cargo run` against live GitHub, not unit tests, so it's not a
|
||||
# meaningful signal here. See Makefile.toml > coverage-report.
|
||||
- name: Coverage
|
||||
run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only
|
||||
|
||||
audit:
|
||||
needs: test
|
||||
runs-on: rust-ci
|
||||
|
|
|
|||
322
Cargo.lock
generated
322
Cargo.lock
generated
|
|
@ -17,6 +17,16 @@ version = "1.0.104"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -117,7 +127,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -129,6 +139,15 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
|
|
@ -213,12 +232,34 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -296,6 +337,18 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
|
|
@ -305,11 +358,30 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
|
|
@ -361,6 +433,12 @@ version = "1.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
|
|
@ -380,9 +458,11 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
|
|
@ -630,12 +710,27 @@ version = "0.2.189"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
|
|
@ -665,6 +760,31 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
|
@ -677,6 +797,29 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -701,10 +844,12 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
"mockito",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
|
@ -717,6 +862,15 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -756,7 +910,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
|
|
@ -792,12 +946,28 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
|
|
@ -806,7 +976,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
|||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -821,7 +1010,16 @@ version = "0.10.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -922,10 +1120,23 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
|
|
@ -1002,6 +1213,12 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
|
@ -1020,6 +1237,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
|
|
@ -1101,6 +1324,18 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
|
|
@ -1134,6 +1369,12 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1210,6 +1451,19 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
|
|
@ -1264,6 +1518,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -1279,6 +1534,20 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.6+spec-1.1.0"
|
||||
|
|
@ -1449,6 +1718,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
|
|
@ -1636,6 +1914,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
|
@ -1665,6 +1949,26 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@ reqwest = { version = "0.13.5", default-features = false, features = ["blocking"
|
|||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
sha2 = "0.11.0"
|
||||
toml = "1.1.6"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1.7.2"
|
||||
tempfile = "3.27.0"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,30 @@ args = ["fmt", "--check"]
|
|||
command = "cargo"
|
||||
args = ["clippy", "--all-targets", "--", "-D", "warnings"]
|
||||
|
||||
# Cognitive complexity (clippy's nursery lint, not literal cyclomatic
|
||||
# complexity — see clippy.toml for why) on the bin target only: run without
|
||||
# --all-targets so #[cfg(test)] code, which naturally reads as more
|
||||
# "complex" without being a maintainability signal, isn't gated on this.
|
||||
[tasks.complexity]
|
||||
command = "cargo"
|
||||
args = ["clippy", "--bins", "--", "-D", "warnings", "-W", "clippy::cognitive_complexity"]
|
||||
|
||||
[tasks.test]
|
||||
command = "cargo"
|
||||
args = ["test"]
|
||||
|
||||
# Named coverage-report, not coverage: cargo-make reserves "coverage" for
|
||||
# its own built-in tarpaulin/kcov-routing composite task, which clobbers a
|
||||
# same-named custom one.
|
||||
#
|
||||
# Reports coverage only; not gated on a threshold yet — main.rs is thin
|
||||
# orchestration glue exercised by the real end-to-end `cargo run`, not unit
|
||||
# tests, so it's excluded here rather than dragging the number down for
|
||||
# reasons unrelated to test quality.
|
||||
[tasks.coverage-report]
|
||||
command = "cargo"
|
||||
args = ["llvm-cov", "--ignore-filename-regex", "main\\.rs", "--summary-only"]
|
||||
|
||||
[tasks.audit]
|
||||
command = "cargo"
|
||||
args = ["audit"]
|
||||
|
|
@ -31,7 +51,7 @@ command = "git"
|
|||
args = ["config", "core.hooksPath", ".githooks"]
|
||||
|
||||
[tasks.ci]
|
||||
dependencies = ["format-check", "lint", "test", "audit"]
|
||||
dependencies = ["format-check", "lint", "complexity", "test", "coverage-report", "audit"]
|
||||
|
||||
[tasks.default]
|
||||
alias = "ci"
|
||||
|
|
|
|||
9
clippy.toml
Normal file
9
clippy.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Shift-left complexity gate (see `cargo make complexity`). Not literal
|
||||
# McCabe cyclomatic complexity — clippy's nursery-tier cognitive_complexity
|
||||
# lint, chosen over the closest real cyclomatic-complexity tool
|
||||
# (rust-code-analysis-cli) because that crate hasn't been released since
|
||||
# early 2023. Threshold set well above the current codebase's max (5, in
|
||||
# `main`) so it flags genuinely tangled control flow without nagging on
|
||||
# today's code; revisit if legitimate future complexity (e.g. the
|
||||
# build/publish pipeline) needs more room.
|
||||
cognitive-complexity-threshold = 15
|
||||
106
src/checker.rs
106
src/checker.rs
|
|
@ -1,3 +1,4 @@
|
|||
use crate::github::GithubEndpoints;
|
||||
use anyhow::{Result, bail};
|
||||
use regex::Regex;
|
||||
|
||||
|
|
@ -14,8 +15,12 @@ use regex::Regex;
|
|||
/// 404s) right after each real release, and that tag sorts newest in the
|
||||
/// feed. So each candidate is confirmed against the releases API in feed
|
||||
/// order, returning the first that actually resolves.
|
||||
pub fn latest_github_release(client: &reqwest::blocking::Client, repo: &str) -> Result<String> {
|
||||
let url = format!("https://github.com/{repo}/releases.atom");
|
||||
pub fn latest_github_release(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoints: &GithubEndpoints,
|
||||
repo: &str,
|
||||
) -> Result<String> {
|
||||
let url = format!("{}/{repo}/releases.atom", endpoints.web);
|
||||
let body = client.get(&url).send()?.error_for_status()?.text()?;
|
||||
|
||||
let re = Regex::new(r#"releases/tag/([^"]+)""#)?;
|
||||
|
|
@ -28,7 +33,7 @@ pub fn latest_github_release(client: &reqwest::blocking::Client, repo: &str) ->
|
|||
}
|
||||
|
||||
for tag in candidates {
|
||||
let release_url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
|
||||
let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
||||
if client.get(&release_url).send()?.status().is_success() {
|
||||
return Ok(tag);
|
||||
}
|
||||
|
|
@ -46,3 +51,98 @@ pub fn latest_github_release(client: &reqwest::blocking::Client, repo: &str) ->
|
|||
pub fn version_from_tag(tag: &str) -> &str {
|
||||
tag.strip_prefix('v').unwrap_or(tag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_from_tag_strips_leading_v() {
|
||||
assert_eq!(version_from_tag("v2.62.0"), "2.62.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_from_tag_leaves_bare_version_unchanged() {
|
||||
assert_eq!(version_from_tag("0.12.15"), "0.12.15");
|
||||
}
|
||||
|
||||
fn atom_feed(tags: &[&str]) -> String {
|
||||
let entries: String = tags
|
||||
.iter()
|
||||
.map(|t| {
|
||||
format!(r#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
|
||||
})
|
||||
.collect();
|
||||
format!("<feed>{entries}</feed>")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_github_release_skips_tags_with_no_real_release() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
|
||||
// Mirrors the real scaleway-cli case: newest feed entry (a -dbg1
|
||||
// tag) has no Release object behind it and 404s.
|
||||
let _feed = server
|
||||
.mock("GET", "/o/r/releases.atom")
|
||||
.with_status(200)
|
||||
.with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"]))
|
||||
.create();
|
||||
let _missing = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1")
|
||||
.with_status(404)
|
||||
.create();
|
||||
let _real = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v2.62.0")
|
||||
.with_status(200)
|
||||
.with_body("{}")
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let tag = latest_github_release(&client, &endpoints, "o/r").unwrap();
|
||||
assert_eq!(tag, "v2.62.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_github_release_errors_when_feed_has_no_tags() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let _feed = server
|
||||
.mock("GET", "/o/r/releases.atom")
|
||||
.with_status(200)
|
||||
.with_body("<feed></feed>")
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
|
||||
assert!(err.to_string().contains("no release tag found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_github_release_errors_when_no_candidate_resolves() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let _feed = server
|
||||
.mock("GET", "/o/r/releases.atom")
|
||||
.with_status(200)
|
||||
.with_body(atom_feed(&["v1.0.0-dbg1"]))
|
||||
.create();
|
||||
let _missing = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1")
|
||||
.with_status(404)
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
|
||||
assert!(err.to_string().contains("resolved to a real release"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
src/config.rs
116
src/config.rs
|
|
@ -58,3 +58,119 @@ pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
|
|||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str, contents: &str) {
|
||||
std::fs::write(dir.join(name), contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_matches_verification_method() {
|
||||
assert_eq!(
|
||||
Verification::SameOriginSha256 {
|
||||
checksum_asset_pattern: "x".to_string()
|
||||
}
|
||||
.tier(),
|
||||
4
|
||||
);
|
||||
assert_eq!(Verification::GithubAttestation.tier(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_same_origin_sha256_package() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
"pkg.toml",
|
||||
r#"
|
||||
[package.pkg]
|
||||
repo = "o/r"
|
||||
asset_pattern = "pkg_{version}_linux_amd64"
|
||||
|
||||
[package.pkg.verification]
|
||||
method = "same-origin-sha256"
|
||||
checksum_asset_pattern = "SHA256SUMS"
|
||||
"#,
|
||||
);
|
||||
|
||||
let packages = load_packages_dir(dir.path()).unwrap();
|
||||
assert_eq!(packages.len(), 1);
|
||||
let (name, pkg) = &packages[0];
|
||||
assert_eq!(name, "pkg");
|
||||
assert_eq!(pkg.repo, "o/r");
|
||||
assert_eq!(pkg.asset_pattern, "pkg_{version}_linux_amd64");
|
||||
assert_eq!(pkg.verification.tier(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_github_attestation_package() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
"pkg.toml",
|
||||
r#"
|
||||
[package.pkg]
|
||||
repo = "o/r"
|
||||
asset_pattern = "pkg-x86_64.tar.gz"
|
||||
|
||||
[package.pkg.verification]
|
||||
method = "github-attestation"
|
||||
"#,
|
||||
);
|
||||
|
||||
let packages = load_packages_dir(dir.path()).unwrap();
|
||||
assert_eq!(packages[0].1.verification.tier(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_multiple_files_and_ignores_non_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
"a.toml",
|
||||
r#"
|
||||
[package.a]
|
||||
repo = "o/a"
|
||||
asset_pattern = "a.tar.gz"
|
||||
[package.a.verification]
|
||||
method = "github-attestation"
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir.path(),
|
||||
"b.toml",
|
||||
r#"
|
||||
[package.b]
|
||||
repo = "o/b"
|
||||
asset_pattern = "b.tar.gz"
|
||||
[package.b.verification]
|
||||
method = "github-attestation"
|
||||
"#,
|
||||
);
|
||||
write(dir.path(), "README.md", "not a package file");
|
||||
|
||||
let mut names: Vec<String> = load_packages_dir(dir.path())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_invalid_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "bad.toml", "not valid toml [[[");
|
||||
assert!(load_packages_dir(dir.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_yields_empty_list() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(load_packages_dir(dir.path()).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
src/fetcher.rs
100
src/fetcher.rs
|
|
@ -1,3 +1,4 @@
|
|||
use crate::github::GithubEndpoints;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -17,12 +18,13 @@ struct Asset {
|
|||
/// into `dest_dir`, returning the local path.
|
||||
pub fn download_asset(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoints: &GithubEndpoints,
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
asset_name: &str,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let api_url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
|
||||
let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
||||
let release: Release = client
|
||||
.get(&api_url)
|
||||
.send()?
|
||||
|
|
@ -46,3 +48,99 @@ pub fn download_asset(
|
|||
std::fs::write(&dest_path, &bytes)?;
|
||||
Ok(dest_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn download_asset_writes_matching_asset_to_dest_dir() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
||||
let release_body = format!(
|
||||
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
|
||||
);
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(200)
|
||||
.with_body(release_body)
|
||||
.create();
|
||||
let _download = server
|
||||
.mock("GET", "/download/thing.tar.gz")
|
||||
.with_status(200)
|
||||
.with_body(b"artifact-bytes".as_slice())
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let dest_dir = tempfile::tempdir().unwrap();
|
||||
let path = download_asset(
|
||||
&client,
|
||||
&endpoints,
|
||||
"o/r",
|
||||
"v1.0.0",
|
||||
"thing.tar.gz",
|
||||
dest_dir.path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(path, dest_dir.path().join("thing.tar.gz"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"artifact-bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_asset_errors_when_no_asset_matches() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"assets": [{"name": "other", "browser_download_url": "https://example.invalid/other"}]}"#)
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let dest_dir = tempfile::tempdir().unwrap();
|
||||
let err = download_asset(
|
||||
&client,
|
||||
&endpoints,
|
||||
"o/r",
|
||||
"v1.0.0",
|
||||
"thing.tar.gz",
|
||||
dest_dir.path(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no asset named"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_asset_errors_when_release_not_found() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(404)
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let dest_dir = tempfile::tempdir().unwrap();
|
||||
let err = download_asset(
|
||||
&client,
|
||||
&endpoints,
|
||||
"o/r",
|
||||
"v1.0.0",
|
||||
"thing.tar.gz",
|
||||
dest_dir.path(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("fetching release metadata"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
src/github.rs
Normal file
29
src/github.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/// Base URLs for GitHub's public web host (Atom feeds, release pages) and
|
||||
/// its REST API, factored out so tests can point both at a local mock
|
||||
/// server instead of the real github.com/api.github.com.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubEndpoints {
|
||||
pub web: String,
|
||||
pub api: String,
|
||||
}
|
||||
|
||||
impl Default for GithubEndpoints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
web: "https://github.com".to_string(),
|
||||
api: "https://api.github.com".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_points_at_real_github() {
|
||||
let endpoints = GithubEndpoints::default();
|
||||
assert_eq!(endpoints.web, "https://github.com");
|
||||
assert_eq!(endpoints.api, "https://api.github.com");
|
||||
}
|
||||
}
|
||||
16
src/main.rs
16
src/main.rs
|
|
@ -1,10 +1,12 @@
|
|||
mod checker;
|
||||
mod config;
|
||||
mod fetcher;
|
||||
mod github;
|
||||
mod state;
|
||||
mod verifier;
|
||||
|
||||
use anyhow::Result;
|
||||
use github::GithubEndpoints;
|
||||
use std::path::Path;
|
||||
|
||||
/// First iteration: check -> fetch -> verify -> report, for whatever is
|
||||
|
|
@ -13,6 +15,7 @@ fn main() -> Result<()> {
|
|||
let client = reqwest::blocking::Client::builder()
|
||||
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)")
|
||||
.build()?;
|
||||
let endpoints = GithubEndpoints::default();
|
||||
|
||||
let packages_dir = Path::new("packages.d");
|
||||
let state_dir = Path::new("state");
|
||||
|
|
@ -27,7 +30,7 @@ fn main() -> Result<()> {
|
|||
for (name, pkg) in packages {
|
||||
println!("== {name} ({}) ==", pkg.repo);
|
||||
|
||||
let latest = checker::latest_github_release(&client, &pkg.repo)?;
|
||||
let latest = checker::latest_github_release(&client, &endpoints, &pkg.repo)?;
|
||||
let last_seen = state::load_last_version(state_dir, &name);
|
||||
|
||||
if last_seen.as_deref() == Some(latest.as_str()) {
|
||||
|
|
@ -41,12 +44,19 @@ fn main() -> Result<()> {
|
|||
let asset_name = pkg
|
||||
.asset_pattern
|
||||
.replace("{version}", checker::version_from_tag(&latest));
|
||||
let artifact_path =
|
||||
fetcher::download_asset(&client, &pkg.repo, &latest, &asset_name, &dest_dir)?;
|
||||
let artifact_path = fetcher::download_asset(
|
||||
&client,
|
||||
&endpoints,
|
||||
&pkg.repo,
|
||||
&latest,
|
||||
&asset_name,
|
||||
&dest_dir,
|
||||
)?;
|
||||
println!(" fetched {}", artifact_path.display());
|
||||
|
||||
let result = verifier::verify(
|
||||
&client,
|
||||
&endpoints,
|
||||
&pkg.verification,
|
||||
&pkg.repo,
|
||||
&latest,
|
||||
|
|
|
|||
42
src/state.rs
42
src/state.rs
|
|
@ -16,3 +16,45 @@ pub fn save_last_version(state_dir: &Path, name: &str, version: &str) -> Result<
|
|||
std::fs::write(state_dir.join(format!("{name}.version")), version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_last_version_missing_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(load_last_version(dir.path(), "nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_roundtrips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_last_version(dir.path(), "uv", "0.12.15").unwrap();
|
||||
assert_eq!(
|
||||
load_last_version(dir.path(), "uv"),
|
||||
Some("0.12.15".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_last_version_trims_trailing_whitespace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("uv.version"), "0.12.15\n").unwrap();
|
||||
assert_eq!(
|
||||
load_last_version(dir.path(), "uv"),
|
||||
Some("0.12.15".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_last_version_creates_state_dir_if_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join("nested/state");
|
||||
save_last_version(&nested, "uv", "0.12.15").unwrap();
|
||||
assert_eq!(
|
||||
load_last_version(&nested, "uv"),
|
||||
Some("0.12.15".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
src/verifier.rs
153
src/verifier.rs
|
|
@ -1,6 +1,7 @@
|
|||
use crate::checker::version_from_tag;
|
||||
use crate::config::Verification;
|
||||
use crate::fetcher;
|
||||
use crate::github::GithubEndpoints;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
|
@ -17,6 +18,7 @@ pub struct VerificationResult {
|
|||
/// each tier does and does not prove.
|
||||
pub fn verify(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoints: &GithubEndpoints,
|
||||
verification: &Verification,
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
|
|
@ -29,8 +31,14 @@ pub fn verify(
|
|||
} => {
|
||||
let checksum_asset_name =
|
||||
checksum_asset_pattern.replace("{version}", version_from_tag(tag));
|
||||
let checksum_path =
|
||||
fetcher::download_asset(client, repo, tag, &checksum_asset_name, dest_dir)?;
|
||||
let checksum_path = fetcher::download_asset(
|
||||
client,
|
||||
endpoints,
|
||||
repo,
|
||||
tag,
|
||||
&checksum_asset_name,
|
||||
dest_dir,
|
||||
)?;
|
||||
let checksum_text = std::fs::read_to_string(&checksum_path)?;
|
||||
let artifact_name = artifact_path
|
||||
.file_name()
|
||||
|
|
@ -127,3 +135,144 @@ fn expected_checksum(checksum_text: &str, artifact_name: &str) -> Result<String>
|
|||
lines.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Verification;
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_bare_single_hash_file() {
|
||||
let text = "f97935763c04be3e692460a7aaeaaab8fc3b78fcf8b389da820b38ae7423a638\n";
|
||||
assert_eq!(
|
||||
expected_checksum(text, "anything.tar.gz").unwrap(),
|
||||
"f97935763c04be3e692460a7aaeaaab8fc3b78fcf8b389da820b38ae7423a638"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_matches_gnu_style_line_by_filename() {
|
||||
let text = "aaaa other-asset\nbbbb scaleway-cli_2.62.0_linux_amd64\ncccc more\n";
|
||||
assert_eq!(
|
||||
expected_checksum(text, "scaleway-cli_2.62.0_linux_amd64").unwrap(),
|
||||
"bbbb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_matches_bsd_style_line_strips_star() {
|
||||
let text = "aaaa *source.tar.gz\nbbbb *thing.tar.gz\n";
|
||||
assert_eq!(expected_checksum(text, "thing.tar.gz").unwrap(), "bbbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_is_case_insensitive() {
|
||||
let text = "ABCDEF thing.tar.gz\n";
|
||||
assert_eq!(expected_checksum(text, "thing.tar.gz").unwrap(), "abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_errors_when_multi_line_file_has_no_match() {
|
||||
let err = expected_checksum("aaaa other\nbbbb another\n", "thing.tar.gz").unwrap_err();
|
||||
assert!(err.to_string().contains("no checksum entry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_checksum_errors_on_empty_file() {
|
||||
let err = expected_checksum("", "thing.tar.gz").unwrap_err();
|
||||
assert!(err.to_string().contains("no checksum entry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_same_origin_sha256_passes_on_matching_checksum() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let dest_dir = tempfile::tempdir().unwrap();
|
||||
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
||||
std::fs::write(&artifact_path, b"hello world").unwrap();
|
||||
let expected_hash = sha256_hex(b"hello world");
|
||||
|
||||
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
||||
let release_body = format!(
|
||||
r#"{{"assets": [{{"name": "SHA256SUMS", "browser_download_url": "{release_url}"}}]}}"#
|
||||
);
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(200)
|
||||
.with_body(release_body)
|
||||
.create();
|
||||
let _sums = server
|
||||
.mock("GET", "/download/SHA256SUMS")
|
||||
.with_status(200)
|
||||
.with_body(format!("{expected_hash} thing.tar.gz\n"))
|
||||
.create();
|
||||
|
||||
let verification = Verification::SameOriginSha256 {
|
||||
checksum_asset_pattern: "SHA256SUMS".to_string(),
|
||||
};
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let result = verify(
|
||||
&client,
|
||||
&endpoints,
|
||||
&verification,
|
||||
"o/r",
|
||||
"v1.0.0",
|
||||
&artifact_path,
|
||||
dest_dir.path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.passed);
|
||||
assert_eq!(result.tier, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_same_origin_sha256_fails_on_mismatched_checksum() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
};
|
||||
let dest_dir = tempfile::tempdir().unwrap();
|
||||
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
||||
std::fs::write(&artifact_path, b"hello world").unwrap();
|
||||
|
||||
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
||||
let release_body = format!(
|
||||
r#"{{"assets": [{{"name": "SHA256SUMS", "browser_download_url": "{release_url}"}}]}}"#
|
||||
);
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(200)
|
||||
.with_body(release_body)
|
||||
.create();
|
||||
let _sums = server
|
||||
.mock("GET", "/download/SHA256SUMS")
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
"0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n",
|
||||
)
|
||||
.create();
|
||||
|
||||
let verification = Verification::SameOriginSha256 {
|
||||
checksum_asset_pattern: "SHA256SUMS".to_string(),
|
||||
};
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let result = verify(
|
||||
&client,
|
||||
&endpoints,
|
||||
&verification,
|
||||
"o/r",
|
||||
"v1.0.0",
|
||||
&artifact_path,
|
||||
dest_dir.path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.passed);
|
||||
assert!(result.justification.contains("mismatch"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue