diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 2a94497..ba4dc93 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 4fde1e1..d366ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 99c7867..6e60695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/Makefile.toml b/Makefile.toml index e215145..e7304d1 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -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" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..148cfc6 --- /dev/null +++ b/clippy.toml @@ -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 diff --git a/src/checker.rs b/src/checker.rs index 2255309..676120d 100644 --- a/src/checker.rs +++ b/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 { - let url = format!("https://github.com/{repo}/releases.atom"); +pub fn latest_github_release( + client: &reqwest::blocking::Client, + endpoints: &GithubEndpoints, + repo: &str, +) -> Result { + 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#""#) + }) + .collect(); + format!("{entries}") + } + + #[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("") + .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")); + } +} diff --git a/src/config.rs b/src/config.rs index 15307e7..b24ab2f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,3 +58,119 @@ pub fn load_packages_dir(dir: &Path) -> Result> { } 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 = 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()); + } +} diff --git a/src/fetcher.rs b/src/fetcher.rs index 0d797fe..398b986 100644 --- a/src/fetcher.rs +++ b/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 { - 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")); + } +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..a511f16 --- /dev/null +++ b/src/github.rs @@ -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"); + } +} diff --git a/src/main.rs b/src/main.rs index 701b5b1..54ffbb5 100644 --- a/src/main.rs +++ b/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, diff --git a/src/state.rs b/src/state.rs index 52a03ec..0bddf7c 100644 --- a/src/state.rs +++ b/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()) + ); + } +} diff --git a/src/verifier.rs b/src/verifier.rs index 3133eae..d7746e2 100644 --- a/src/verifier.rs +++ b/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 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")); + } +}