//! 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, } #[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 { 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")); } }