diff --git a/docs/SPEC.md b/docs/SPEC.md index 3361cbe..842d338 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -294,10 +294,26 @@ implements `repo`, `asset_pattern`, and `verification.method` `sanity_check` and `binary_name` are now real, implemented fields (see Builder/Sanity checker above) — added `packages.d/uv.toml`'s and `packages.d/scaleway-cli.toml`'s own `sanity_check` blocks, and -scaleway-cli's `binary_name = "scw"`. `source`, `check_method`, and -`check_interval` are still schema sketch, not yet read by the code — the -PoC only knows how to check GitHub-release sources, on a single one-shot -run rather than a scheduled loop. +scaleway-cli's `binary_name = "scw"`. `source` is implemented too, with +two values: `github-release` (the default when omitted, so existing +configs are unchanged) and `forgejo-release`, which also requires a +`base_url` (see Checker below). `check_method` and `check_interval` are +still schema sketch, not yet read by the code — checks are a fixed hourly +tick, not per-package. + +```toml +# A package released from a Forgejo instance instead of GitHub. Only +# `same-origin-sha256` is valid here: `github-attestation` needs GitHub. +[package.mytool] +source = "forgejo-release" +base_url = "https://code.austinschaefer.com" +repo = "schaefera/mytool" +asset_pattern = "mytool-linux-x86_64.tar.gz" + +[package.mytool.verification] +method = "same-origin-sha256" +checksum_asset_pattern = "SHA256SUMS" +``` Build/publish/review-queue (`makepkg`, `repo-add`, tier 4–6 human review) are now implemented too — see Builder/Sanity checker/Publisher/Reviewer @@ -357,15 +373,32 @@ Open questions on the schema: likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub sources eventually. For GitHub sources, prefers the `github-atom` feed (see Scaling > Check method) over unconditional REST polling. - *(Implemented for GitHub only — `src/checker.rs` regex-matches the first - `releases/tag/` link in the feed rather than doing a full XML parse; - fine while the feed's newest-entry-first shape holds, revisit if that - ever changes. `check_interval`/per-package cadence not wired up yet — - the PoC is a single one-shot run, not a scheduled loop.)* + *(Implemented for GitHub and Forgejo — `src/checker.rs`. GitHub + regex-matches the first `releases/tag/` link in the feed rather than + doing a full XML parse; fine while the feed's newest-entry-first shape + holds, revisit if that ever changes. Forgejo is one call to + `/api/v1/repos//releases/latest`, which already returns + only the newest non-draft, non-prerelease release, so it needs none of + GitHub's confirm-each-tag step. `check_interval`/per-package cadence not + wired up yet — checks are a fixed hourly tick.)* + + Both hosts' HTTP is hand-rolled on the `reqwest` already in the tree, + not an API-client crate: what pkgwatch needs is two `GET`s + (latest-release, release-by-tag), GitHub's check deliberately uses the + Atom feed that no API crate covers (to stay off the rate-limited REST + API), and the release-by-tag call is shared verbatim between the two + hosts, which two per-host crates would split in two. `octocrab` is + async/tokio/hyper against this project's blocking `reqwest`, and its + default tree alone (217 crates) is larger than all of pkgwatch's today + (143); `forgejo-api` has a `sync` feature but is a generated binding of + the whole Forgejo API for one endpoint. Revisit if pkgwatch ever needs + authenticated or write API calls (e.g. publishing its own releases from + code rather than CI). - **Fetcher**: downloads the artifact (and any checksum/signature/ attestation companion) for a resolved version. *(Implemented — - `src/fetcher.rs`, via the GitHub releases API; exact asset-name match, - not a glob.)* + `src/fetcher.rs`, via the GitHub or Forgejo releases API — same + `releases/tags/` endpoint and JSON shape on both; exact asset-name + match, not a glob.)* - **Verifier**: tier-specific verification implementations, dispatched via a `Verification` enum matched on `method` (an internally-tagged serde enum) rather than a trait — simpler while there are only two methods; @@ -517,7 +550,7 @@ Open questions on the schema: - [ ] Not yet implemented: `pkgwatch review --reject` (a pending review can only be approved or left pending, not dismissed), per-package `check_interval` (the timer is a fixed hourly tick), - non-GitHub sources, `minisign`/tier-1 + sources other than GitHub and Forgejo releases, `minisign`/tier-1 method, retention/pruning of old versions in the local repo (see Scaling > Local repo retention), staggering/auth for GitHub API rate limits at higher package counts. diff --git a/src/checker.rs b/src/checker.rs index 676120d..4f2057e 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,6 +1,51 @@ use crate::github::GithubEndpoints; -use anyhow::{Result, bail}; +use crate::source::Endpoints; +use anyhow::{Context, Result, bail}; use regex::Regex; +use serde::Deserialize; + +/// Resolves the latest release tag for `repo` on whichever service +/// `endpoints` points at. +pub fn latest_release( + client: &reqwest::blocking::Client, + endpoints: &Endpoints, + repo: &str, +) -> Result { + match endpoints { + Endpoints::Github(github) => latest_github_release(client, github, repo), + Endpoints::Forgejo { api } => latest_forgejo_release(client, api, repo), + } +} + +/// Resolves the latest release tag for `repo` on a Forgejo/Gitea instance. +/// +/// One call, unlike GitHub's feed-then-confirm dance below: Forgejo's +/// `releases/latest` already returns only the newest non-draft, +/// non-prerelease *release object*, so a stray tag with no release behind +/// it (GitHub's scaleway-cli `-dbg1` problem) can't be returned. +pub fn latest_forgejo_release( + client: &reqwest::blocking::Client, + api: &str, + repo: &str, +) -> Result { + #[derive(Deserialize)] + struct Latest { + tag_name: String, + } + + let url = format!("{api}/repos/{repo}/releases/latest"); + let response = client.get(&url).send()?; + // Forgejo answers 404 both for an unknown repo and for one with no + // releases yet — the common state for a project's very first release. + if response.status() == reqwest::StatusCode::NOT_FOUND { + bail!("no published release found at {url} (repo missing, or nothing released yet)"); + } + let latest: Latest = response + .error_for_status() + .with_context(|| format!("fetching latest release from {url}"))? + .json()?; + Ok(latest.tag_name) +} /// Resolves the latest release tag for `repo` via its public Atom feed. /// @@ -145,4 +190,73 @@ mod tests { let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err(); assert!(err.to_string().contains("resolved to a real release")); } + + #[test] + fn latest_forgejo_release_returns_tag_name() { + let mut server = mockito::Server::new(); + let _latest = server + .mock("GET", "/repos/o/r/releases/latest") + .with_status(200) + .with_body(r#"{"tag_name": "v0.1.0", "assets": []}"#) + .create(); + + let client = reqwest::blocking::Client::new(); + let tag = latest_forgejo_release(&client, &server.url(), "o/r").unwrap(); + assert_eq!(tag, "v0.1.0"); + } + + #[test] + fn latest_forgejo_release_names_the_no_releases_case() { + let mut server = mockito::Server::new(); + let _latest = server + .mock("GET", "/repos/o/r/releases/latest") + .with_status(404) + .create(); + + let client = reqwest::blocking::Client::new(); + let err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err(); + assert!(err.to_string().contains("no published release")); + } + + #[test] + fn latest_forgejo_release_surfaces_server_errors() { + let mut server = mockito::Server::new(); + let _latest = server + .mock("GET", "/repos/o/r/releases/latest") + .with_status(500) + .create(); + + let client = reqwest::blocking::Client::new(); + let err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err(); + assert!(err.to_string().contains("fetching latest release")); + } + + #[test] + fn latest_release_dispatches_on_endpoint_kind() { + let mut server = mockito::Server::new(); + let _forgejo = server + .mock("GET", "/repos/o/r/releases/latest") + .with_status(200) + .with_body(r#"{"tag_name": "v2.0.0"}"#) + .create(); + let _feed = server + .mock("GET", "/o/r/releases.atom") + .with_body(atom_feed(&["v1.0.0"])) + .create(); + let _release = server + .mock("GET", "/repos/o/r/releases/tags/v1.0.0") + .with_status(200) + .with_body("{}") + .create(); + + let client = reqwest::blocking::Client::new(); + let forgejo = Endpoints::Forgejo { api: server.url() }; + assert_eq!(latest_release(&client, &forgejo, "o/r").unwrap(), "v2.0.0"); + + let github = Endpoints::Github(GithubEndpoints { + web: server.url(), + api: server.url(), + }); + assert_eq!(latest_release(&client, &github, "o/r").unwrap(), "v1.0.0"); + } } diff --git a/src/config.rs b/src/config.rs index 9c242b2..5123dd7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ //! The only module that knows the TOML shape — everything downstream //! works with `Package`/`Verification`/`SanityCheck`, never raw TOML. -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use serde::Deserialize; use std::collections::{BTreeMap, HashMap}; use std::path::Path; @@ -12,9 +12,28 @@ struct PackageFile { package: HashMap, } +/// Where a package's releases are published: the `source` key from +/// docs/SPEC.md > Config schema. Omitted means GitHub, so every existing +/// `packages.d/*.toml` keeps working. +#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Source { + #[default] + GithubRelease, + /// A Forgejo (or Gitea) instance; needs `base_url` too. + ForgejoRelease, +} + #[derive(Debug, Deserialize, Clone)] pub struct Package { + /// `owner/name` on whichever `source` hosts it. pub repo: String, + #[serde(default)] + pub source: Source, + /// Web root of the Forgejo instance, e.g. `https://code.austinschaefer.com` + /// (the API lives under `/api/v1`). Required for, and only meaningful + /// with, `source = "forgejo-release"`. + pub base_url: Option, /// Exact GitHub release asset name (still not a glob — see /// docs/SPEC.md > Architecture > Fetcher), optionally containing a /// `{version}` placeholder for projects whose asset names embed the @@ -60,6 +79,32 @@ pub struct Package { } impl Package { + /// Rejects combinations that can't work, once at load time rather than + /// as a confusing failure deep in a run (see docs/ARCHITECTURE.md > + /// "validate at the boundary, once"). + fn validate(&self, name: &str) -> Result<()> { + match (self.source, &self.base_url) { + (Source::GithubRelease, None) => {} + (Source::GithubRelease, Some(_)) => { + // Silently ignoring it would hide a mistyped `source`. + bail!("{name}: base_url only applies to source = \"forgejo-release\""); + } + (Source::ForgejoRelease, None) => { + bail!("{name}: source = \"forgejo-release\" needs a base_url"); + } + (Source::ForgejoRelease, Some(url)) => { + if !url.starts_with("https://") && !url.starts_with("http://") { + bail!("{name}: base_url '{url}' must start with http:// or https://"); + } + // `gh attestation verify` only speaks GitHub's attestation API. + if matches!(self.verification, Verification::GithubAttestation) { + bail!("{name}: github-attestation verification needs a GitHub source"); + } + } + } + Ok(()) + } + /// The name of the executable inside the built package: `binary_name` /// if the package declares one, else `pkg_name` itself. pub fn binary_name<'a>(&'a self, pkg_name: &'a str) -> &'a str { @@ -107,6 +152,10 @@ pub fn load_packages_dir(dir: &Path) -> Result> { .with_context(|| format!("reading {}", path.display()))?; let file: PackageFile = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; + for (name, pkg) in &file.package { + pkg.validate(name) + .with_context(|| format!("in {}", path.display()))?; + } out.extend(file.package); } Ok(out) @@ -258,4 +307,79 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert!(load_packages_dir(dir.path()).unwrap().is_empty()); } + + /// One `[package.p]` with the given extra top-level lines and + /// verification table, loaded through the real loader so validation + /// runs too. + fn load_one(extra: &str, verification: &str) -> Result { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "p.toml", + &format!( + "[package.p]\nrepo = \"o/r\"\nasset_pattern = \"x\"\n{extra}\n\ + [package.p.verification]\n{verification}\n" + ), + ); + let mut loaded = load_packages_dir(dir.path())?; + Ok(loaded.remove(0).1) + } + + const SAME_ORIGIN: &str = "method = \"same-origin-sha256\"\nchecksum_asset_pattern = \"SUMS\""; + const ATTESTATION: &str = "method = \"github-attestation\""; + + const FORGEJO: &str = "source = \"forgejo-release\"\nbase_url = \"https://forge.example.com\""; + + #[test] + fn source_defaults_to_github_release() { + let pkg = load_one("", ATTESTATION).unwrap(); + assert_eq!(pkg.source, Source::GithubRelease); + assert_eq!(pkg.base_url, None); + } + + #[test] + fn loads_explicit_github_release_source() { + let pkg = load_one("source = \"github-release\"", ATTESTATION).unwrap(); + assert_eq!(pkg.source, Source::GithubRelease); + } + + #[test] + fn loads_forgejo_release_source() { + let pkg = load_one(FORGEJO, SAME_ORIGIN).unwrap(); + assert_eq!(pkg.source, Source::ForgejoRelease); + assert_eq!(pkg.base_url.as_deref(), Some("https://forge.example.com")); + } + + #[test] + fn rejects_forgejo_release_without_base_url() { + let err = load_one("source = \"forgejo-release\"", SAME_ORIGIN).unwrap_err(); + assert!(format!("{err:#}").contains("needs a base_url")); + } + + #[test] + fn rejects_base_url_on_a_github_source() { + let err = load_one("base_url = \"https://forge.example.com\"", SAME_ORIGIN).unwrap_err(); + assert!(format!("{err:#}").contains("only applies to source")); + } + + #[test] + fn rejects_forgejo_base_url_without_scheme() { + let err = load_one( + "source = \"forgejo-release\"\nbase_url = \"forge.example.com\"", + SAME_ORIGIN, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("must start with http")); + } + + #[test] + fn rejects_github_attestation_on_a_forgejo_source() { + let err = load_one(FORGEJO, ATTESTATION).unwrap_err(); + assert!(format!("{err:#}").contains("needs a GitHub source")); + } + + #[test] + fn rejects_unknown_source() { + assert!(load_one("source = \"gitlab-release\"", SAME_ORIGIN).is_err()); + } } diff --git a/src/fetcher.rs b/src/fetcher.rs index 2614258..4770ac8 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -1,8 +1,7 @@ -//! Downloads a named GitHub release asset to a local path. The only -//! module that talks to the releases API for asset bytes — `checker` only -//! resolves version tags, never downloads. +//! Downloads a named release asset (from GitHub or Forgejo) to a local +//! path. The only module that talks to the releases API for asset bytes — +//! `checker` only resolves version tags, never downloads. -use crate::github::GithubEndpoints; use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; @@ -29,16 +28,18 @@ pub struct DownloadedAsset { } /// Downloads the release asset named exactly `asset_name` for `repo`@`tag` -/// into `dest_dir`, returning the local path and its origin URL. +/// into `dest_dir`, returning the local path and its origin URL. `api` is +/// the releases API root (see `source::Endpoints::api`); GitHub and Forgejo +/// serve the same endpoint and JSON shape under it. pub fn download_asset( client: &reqwest::blocking::Client, - endpoints: &GithubEndpoints, + api: &str, repo: &str, tag: &str, asset_name: &str, dest_dir: &Path, ) -> Result { - let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api); + let api_url = format!("{api}/repos/{repo}/releases/tags/{tag}"); let release: Release = client .get(&api_url) .send()? @@ -73,10 +74,7 @@ mod tests { #[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 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}"}}]}}"# @@ -96,7 +94,7 @@ mod tests { let dest_dir = tempfile::tempdir().unwrap(); let asset = download_asset( &client, - &endpoints, + &api, "o/r", "v1.0.0", "thing.tar.gz", @@ -112,10 +110,7 @@ mod tests { #[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 api = server.url(); let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(200) @@ -126,7 +121,7 @@ mod tests { let dest_dir = tempfile::tempdir().unwrap(); let err = download_asset( &client, - &endpoints, + &api, "o/r", "v1.0.0", "thing.tar.gz", @@ -139,10 +134,7 @@ mod tests { #[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 api = server.url(); let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(404) @@ -152,7 +144,7 @@ mod tests { let dest_dir = tempfile::tempdir().unwrap(); let err = download_asset( &client, - &endpoints, + &api, "o/r", "v1.0.0", "thing.tar.gz", diff --git a/src/main.rs b/src/main.rs index e238779..9b9255b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod paths; mod pipeline; mod publisher; mod sanity; +mod source; mod state; #[cfg(test)] mod test_support; diff --git a/src/pipeline.rs b/src/pipeline.rs index 753698e..c9f523e 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -9,11 +9,11 @@ use crate::builder; use crate::checker; use crate::config::{self, Package}; use crate::fetcher::{self, DownloadedAsset}; -use crate::github::GithubEndpoints; use crate::notifier::{self, Event}; use crate::paths::Paths; use crate::publisher; use crate::sanity; +use crate::source::Endpoints; use crate::state; use crate::verifier::{self, VerificationResult}; use anyhow::{Context, Result, bail}; @@ -57,7 +57,6 @@ fn load_packages(packages_dir: &Path) -> Result> { pub fn run_check() -> Result<()> { let client = build_client()?; - let endpoints = GithubEndpoints::default(); let Paths { packages_dir, state_dir, @@ -73,7 +72,10 @@ pub fn run_check() -> Result<()> { let mut any_failed = false; for (name, pkg) in &packages { println!("== {name} ({}) ==", pkg.repo); - if let Err(err) = process_package(&client, &endpoints, &state_dir, &work_dir, name, pkg) { + let result = Endpoints::for_package(pkg).and_then(|endpoints| { + process_package(&client, &endpoints, &state_dir, &work_dir, name, pkg) + }); + if let Err(err) = result { eprintln!(" error: {err:#}"); any_failed = true; } @@ -118,13 +120,13 @@ fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool fn process_package( client: &reqwest::blocking::Client, - endpoints: &GithubEndpoints, + endpoints: &Endpoints, state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, ) -> Result<()> { - let latest = checker::latest_github_release(client, endpoints, &pkg.repo)?; + let latest = checker::latest_release(client, endpoints, &pkg.repo)?; let last_seen = state::load_last_version(state_dir, name); if last_seen.as_deref() == Some(latest.as_str()) { println!(" up to date at {latest}"); @@ -205,7 +207,7 @@ struct FetchVerifyResult { /// trusting a possibly-stale flag from an earlier run). fn fetch_and_verify( client: &reqwest::blocking::Client, - endpoints: &GithubEndpoints, + endpoints: &Endpoints, work_dir: &Path, name: &str, pkg: &Package, @@ -215,10 +217,11 @@ fn fetch_and_verify( let asset_name = pkg.asset_pattern.replace("{version}", &version); let dest_dir = work_dir.join(name).join(tag); - let asset = fetcher::download_asset(client, endpoints, &pkg.repo, tag, &asset_name, &dest_dir)?; + let api = endpoints.api(); + let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?; let verification = verifier::verify( client, - endpoints, + api, &pkg.verification, &pkg.repo, tag, @@ -317,7 +320,7 @@ pub fn run_review(args: &[String]) -> Result<()> { fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> { let client = build_client()?; - let endpoints = GithubEndpoints::default(); + let endpoints = Endpoints::for_package(pkg)?; // Re-verify rather than trusting the earlier flag: the artifact at // this tag could in principle have changed since it was queued. @@ -343,6 +346,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s #[cfg(test)] mod tests { use super::*; + use crate::github::GithubEndpoints; #[test] fn decide_tier_action_failed_verification_overrides_everything() { @@ -374,6 +378,83 @@ mod tests { assert_eq!(decide_tier_action(4, true, true), TierAction::StillPending); } + /// Mocks the release-tag, asset, and checksum endpoints for `o/r@v1.0.0` + /// with a checksum that doesn't match the asset, so verification fails. + /// These are identical on GitHub and Forgejo (see `Endpoints::api`); only + /// how the latest tag is found differs per test. Returned mocks must + /// stay alive for the test's duration. + fn mock_release_with_bad_checksum(server: &mut mockito::ServerGuard) -> Vec { + let asset_url = format!("{}/download/thing.tar.gz", server.url()); + let sums_url = format!("{}/download/SHA256SUMS", server.url()); + let release_body = format!( + r#"{{"assets": [ + {{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}, + {{"name": "SHA256SUMS", "browser_download_url": "{sums_url}"}} + ]}}"# + ); + vec![ + server + .mock("GET", "/repos/o/r/releases/tags/v1.0.0") + .with_status(200) + .with_body(release_body) + .create(), + server + .mock("GET", "/download/thing.tar.gz") + .with_status(200) + .with_body(b"artifact-bytes".as_slice()) + .create(), + // Wrong hash for "artifact-bytes" — forces a verification failure. + server + .mock("GET", "/download/SHA256SUMS") + .with_status(200) + .with_body( + "0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n", + ) + .create(), + ] + } + + /// `pkg_toml_extra` is spliced in before the `[verification]` table, so + /// it can carry top-level keys like `source`. + fn same_origin_package(pkg_toml_extra: &str) -> Package { + toml::from_str(&format!( + r#" + repo = "o/r" + asset_pattern = "thing.tar.gz" + {pkg_toml_extra} + [verification] + method = "same-origin-sha256" + checksum_asset_pattern = "SHA256SUMS" + "# + )) + .unwrap() + } + + /// Runs `process_package` for a package whose checksum is wrong and + /// asserts it errors with "verification failed" while leaving no trace + /// in state. + fn assert_verification_failure_is_an_error(endpoints: &Endpoints, pkg: &Package) { + let client = reqwest::blocking::Client::new(); + let state_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let err = process_package( + &client, + endpoints, + state_dir.path(), + work_dir.path(), + "thing", + pkg, + ) + .unwrap_err(); + + assert!(err.to_string().contains("verification failed")); + // Neither published nor queued for review — a failed verification + // shouldn't leave any trace in state. + assert_eq!(state::load_last_version(state_dir.path(), "thing"), None); + assert_eq!(state::load_pending_version(state_dir.path(), "thing"), None); + } + /// Regression test for the exit-code gap this PR fixes: a verification /// failure previously returned `Ok(())` from `process_package`, so /// `run_check` never counted it as a failure and the process exited 0 @@ -384,11 +465,10 @@ mod tests { #[test] fn process_package_returns_err_on_verification_failure() { let mut server = mockito::Server::new(); - let endpoints = GithubEndpoints { + let endpoints = Endpoints::Github(GithubEndpoints { web: server.url(), api: server.url(), - }; - + }); let feed = format!( r#""#, server.url() @@ -398,63 +478,28 @@ mod tests { .with_status(200) .with_body(feed) .create(); + let _release_mocks = mock_release_with_bad_checksum(&mut server); - let asset_url = format!("{}/download/thing.tar.gz", server.url()); - let sums_url = format!("{}/download/SHA256SUMS", server.url()); - let release_body = format!( - r#"{{"assets": [ - {{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}, - {{"name": "SHA256SUMS", "browser_download_url": "{sums_url}"}} - ]}}"# + assert_verification_failure_is_an_error(&endpoints, &same_origin_package("")); + } + + /// Same as above but through a Forgejo source, proving the whole + /// check -> fetch -> verify path works there too: the error is the + /// verification failure, not a fetch or check failure on the way to it. + #[test] + fn process_package_returns_err_on_verification_failure_via_forgejo() { + let mut server = mockito::Server::new(); + let endpoints = Endpoints::Forgejo { api: server.url() }; + let _latest = server + .mock("GET", "/repos/o/r/releases/latest") + .with_status(200) + .with_body(r#"{"tag_name": "v1.0.0"}"#) + .create(); + let _release_mocks = mock_release_with_bad_checksum(&mut server); + + let pkg = same_origin_package( + "source = \"forgejo-release\"\nbase_url = \"https://forge.example.com\"", ); - let _release = server - .mock("GET", "/repos/o/r/releases/tags/v1.0.0") - .with_status(200) - .with_body(release_body) - .create(); - let _asset = server - .mock("GET", "/download/thing.tar.gz") - .with_status(200) - .with_body(b"artifact-bytes".as_slice()) - .create(); - // Wrong hash for "artifact-bytes" — forces a verification failure. - let _sums = server - .mock("GET", "/download/SHA256SUMS") - .with_status(200) - .with_body( - "0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n", - ) - .create(); - - let pkg: Package = toml::from_str( - r#" - repo = "o/r" - asset_pattern = "thing.tar.gz" - [verification] - method = "same-origin-sha256" - checksum_asset_pattern = "SHA256SUMS" - "#, - ) - .unwrap(); - - let client = reqwest::blocking::Client::new(); - let state_dir = tempfile::tempdir().unwrap(); - let work_dir = tempfile::tempdir().unwrap(); - - let err = process_package( - &client, - &endpoints, - state_dir.path(), - work_dir.path(), - "thing", - &pkg, - ) - .unwrap_err(); - - assert!(err.to_string().contains("verification failed")); - // Neither published nor queued for review — a failed verification - // shouldn't leave any trace in state. - assert_eq!(state::load_last_version(state_dir.path(), "thing"), None); - assert_eq!(state::load_pending_version(state_dir.path(), "thing"), None); + assert_verification_failure_is_an_error(&endpoints, &pkg); } } diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..03bd059 --- /dev/null +++ b/src/source.rs @@ -0,0 +1,94 @@ +//! Maps a package's configured `source` to the endpoints the pipeline +//! stages talk to. The only module that knows how each hosting service +//! lays out its URLs; `checker` and `fetcher` take what it hands them. + +use crate::config::{Package, Source}; +use crate::github::GithubEndpoints; +use anyhow::{Context, Result}; + +#[derive(Debug, Clone)] +pub enum Endpoints { + Github(GithubEndpoints), + /// A Forgejo/Gitea instance's API root, i.e. `/api/v1`. + Forgejo { + api: String, + }, +} + +impl Endpoints { + /// Errors only if a `forgejo-release` package has no `base_url`, which + /// `config::load_packages_dir` already rejects — this is the same check + /// again for a `Package` built some other way, not a second source of + /// truth. + pub fn for_package(pkg: &Package) -> Result { + match pkg.source { + Source::GithubRelease => Ok(Endpoints::Github(GithubEndpoints::default())), + Source::ForgejoRelease => { + let base_url = pkg + .base_url + .as_deref() + .context("source = \"forgejo-release\" needs a base_url")?; + Ok(Endpoints::Forgejo { + api: format!("{}/api/v1", base_url.trim_end_matches('/')), + }) + } + } + } + + /// The releases API root. GitHub and Forgejo both serve + /// `/repos/{owner}/{repo}/releases/tags/{tag}` under it with the same + /// `assets[].{name, browser_download_url}` shape, which is why + /// `fetcher` and `verifier` need only this and not the enum. + pub fn api(&self) -> &str { + match self { + Endpoints::Github(github) => &github.api, + Endpoints::Forgejo { api } => api, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package(extra: &str) -> Package { + toml::from_str(&format!( + r#" + repo = "o/r" + asset_pattern = "x" + {extra} + [verification] + method = "same-origin-sha256" + checksum_asset_pattern = "SUMS" + "# + )) + .unwrap() + } + + #[test] + fn github_source_uses_real_github() { + let endpoints = Endpoints::for_package(&package("")).unwrap(); + assert_eq!(endpoints.api(), "https://api.github.com"); + assert!(matches!(endpoints, Endpoints::Github(_))); + } + + #[test] + fn forgejo_source_appends_api_v1() { + let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com\""); + let endpoints = Endpoints::for_package(&pkg).unwrap(); + assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); + } + + #[test] + fn forgejo_source_tolerates_trailing_slash() { + let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com/\""); + let endpoints = Endpoints::for_package(&pkg).unwrap(); + assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); + } + + #[test] + fn forgejo_source_without_base_url_errors() { + let err = Endpoints::for_package(&package("source = \"forgejo-release\"")).unwrap_err(); + assert!(err.to_string().contains("needs a base_url")); + } +} diff --git a/src/verifier.rs b/src/verifier.rs index 7ffa4d9..7b301d6 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -6,7 +6,6 @@ use crate::checker::version_from_tag; use crate::config::Verification; use crate::fetcher; -use crate::github::GithubEndpoints; use crate::hash; use anyhow::{Context, Result, bail}; use std::path::Path; @@ -23,7 +22,7 @@ pub struct VerificationResult { /// each tier does and does not prove. pub fn verify( client: &reqwest::blocking::Client, - endpoints: &GithubEndpoints, + api: &str, verification: &Verification, repo: &str, tag: &str, @@ -36,14 +35,8 @@ pub fn verify( } => { let checksum_asset_name = checksum_asset_pattern.replace("{version}", version_from_tag(tag)); - let checksum_asset = fetcher::download_asset( - client, - endpoints, - repo, - tag, - &checksum_asset_name, - dest_dir, - )?; + let checksum_asset = + fetcher::download_asset(client, api, repo, tag, &checksum_asset_name, dest_dir)?; let checksum_text = std::fs::read_to_string(&checksum_asset.path)?; let artifact_name = artifact_path .file_name() @@ -184,10 +177,7 @@ mod tests { #[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 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(); @@ -214,7 +204,7 @@ mod tests { let client = reqwest::blocking::Client::new(); let result = verify( &client, - &endpoints, + &api, &verification, "o/r", "v1.0.0", @@ -230,10 +220,7 @@ mod tests { #[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 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(); @@ -261,7 +248,7 @@ mod tests { let client = reqwest::blocking::Client::new(); let result = verify( &client, - &endpoints, + &api, &verification, "o/r", "v1.0.0",