pkgwatch/src/fetcher.rs
Austin Schaefer eef98906b6 Add a Forgejo release source
Packages can now declare source = "forgejo-release" plus a base_url and be
checked, fetched and verified against a Forgejo instance's releases API,
alongside the existing GitHub source. This is what lets pkgwatch track its
own releases from the self-hosted Forgejo.

- config: Source enum (github-release default, forgejo-release) + base_url,
  validated once at load (base_url pairing, http(s) scheme, and no
  github-attestation on a Forgejo source).
- source: new module mapping a package to its Endpoints.
- checker: latest_forgejo_release, one call to releases/latest; latest_release
  dispatches per source.
- fetcher/verifier: take the releases API root instead of GithubEndpoints,
  since GitHub and Forgejo serve the same releases/tags/<tag> shape.
- pipeline: endpoints are resolved per package.
- docs: SPEC documents the source key and why the HTTP is hand-rolled rather
  than an API-client crate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:36:13 +02:00

156 lines
4.8 KiB
Rust

//! 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 anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct Release {
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
/// A downloaded release asset: its local path plus the URL it came from,
/// the latter needed for the `source=` line of a generated PKGBUILD (see
/// `builder`) — `makepkg` uses it only as a fallback if the pre-seeded
/// local copy ever goes missing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadedAsset {
pub path: PathBuf,
pub download_url: String,
}
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
/// 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,
api: &str,
repo: &str,
tag: &str,
asset_name: &str,
dest_dir: &Path,
) -> Result<DownloadedAsset> {
let api_url = format!("{api}/repos/{repo}/releases/tags/{tag}");
let release: Release = client
.get(&api_url)
.send()?
.error_for_status()
.with_context(|| format!("fetching release metadata from {api_url}"))?
.json()?;
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.with_context(|| format!("no asset named '{asset_name}' in {repo}@{tag}"))?;
std::fs::create_dir_all(dest_dir)?;
let dest_path = dest_dir.join(&asset.name);
let bytes = client
.get(&asset.browser_download_url)
.send()?
.error_for_status()?
.bytes()?;
std::fs::write(&dest_path, &bytes)?;
Ok(DownloadedAsset {
path: dest_path,
download_url: asset.browser_download_url.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_asset_writes_matching_asset_to_dest_dir() {
let mut server = mockito::Server::new();
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}"}}]}}"#
);
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 asset = download_asset(
&client,
&api,
"o/r",
"v1.0.0",
"thing.tar.gz",
dest_dir.path(),
)
.unwrap();
assert_eq!(asset.path, dest_dir.path().join("thing.tar.gz"));
assert_eq!(asset.download_url, asset_url);
assert_eq!(std::fs::read(&asset.path).unwrap(), b"artifact-bytes");
}
#[test]
fn download_asset_errors_when_no_asset_matches() {
let mut server = mockito::Server::new();
let 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,
&api,
"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 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,
&api,
"o/r",
"v1.0.0",
"thing.tar.gz",
dest_dir.path(),
)
.unwrap_err();
assert!(err.to_string().contains("fetching release metadata"));
}
}