//! Orchestrates one run of check -> fetch -> verify -> build -> //! sanity-check -> publish across every configured package, plus the //! `review` subcommand for tier 4-6 approvals. The only module that calls //! more than one other pipeline-stage module — see docs/ARCHITECTURE.md > "main //! is a dispatcher, not the program" for why this lives here and not in //! `main.rs`. use crate::builder; use crate::checker; use crate::config::{self, Package}; use crate::fetcher::{self, DownloadedAsset}; 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}; use std::path::{Path, PathBuf}; /// Not a repo pkgwatch invents: this is the existing, already-registered /// local pacman repo on this box (see `[custom]` in /etc/pacman.conf and /// its `Server = file://...` line). pkgwatch adds packages to it; it does /// not create the repo or touch pacman.conf. const CUSTOM_REPO_NAME: &str = "custom"; const CUSTOM_REPO_SUBPATH: &str = ".local/share/pacman/custom"; fn build_client() -> Result { Ok(reqwest::blocking::Client::builder() .user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)") .build()?) } fn custom_repo_dir() -> Result { // Override for testing against a scratch repo instead of the real one // at $HOME/.local/share/pacman/custom. if let Ok(dir) = std::env::var("PKGWATCH_REPO_DIR") { return Ok(PathBuf::from(dir)); } let home = std::env::var("HOME").context("HOME is not set")?; Ok(Path::new(&home).join(CUSTOM_REPO_SUBPATH)) } /// Adds a hint to the bare "No such file" a missing config dir would give: /// the dir is no longer relative to the cwd, so a checkout's `packages.d/` /// isn't picked up on its own (see docs/SPEC.md > Paths). fn load_packages(packages_dir: &Path) -> Result> { config::load_packages_dir(packages_dir).with_context(|| { format!( "no package config at {} (set PKGWATCH_CONFIG_DIR to the directory containing \ packages.d, or see docs/SPEC.md > Paths for moving a checkout's packages.d/ there)", packages_dir.display() ) }) } pub fn run_check() -> Result<()> { let client = build_client()?; let Paths { packages_dir, state_dir, work_dir, } = Paths::from_env()?; let packages = load_packages(&packages_dir)?; if packages.is_empty() { println!("no packages configured under {}/", packages_dir.display()); return Ok(()); } let mut any_failed = false; for (name, pkg) in &packages { println!("== {name} ({}) ==", pkg.repo); 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; } } if any_failed { bail!("one or more packages failed — see errors above"); } Ok(()) } /// What to do about a package after verification, derived purely from the /// verification outcome and whether this exact version is already queued /// for review — no I/O. See docs/ARCHITECTURE.md > "separate pure decision /// logic from I/O." #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TierAction { /// Verification failed outright — don't build, don't touch state. VerificationFailed, /// Tier 1-3: safe to auto-build and publish immediately. Publish, /// Tier 4-6, and this exact version was already flagged on an earlier /// run — nothing new to report. StillPending, /// Tier 4-6, and this version hasn't been flagged yet. NewlyPending, } fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool) -> TierAction { if !passed { return TierAction::VerificationFailed; } if tier <= 3 { return TierAction::Publish; } if already_pending_this_version { TierAction::StillPending } else { TierAction::NewlyPending } } fn process_package( client: &reqwest::blocking::Client, endpoints: &Endpoints, state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, ) -> Result<()> { 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}"); return Ok(()); } println!(" new version detected: {latest} (previously: {last_seen:?})"); let fetched = fetch_and_verify(client, endpoints, work_dir, name, pkg, &latest)?; println!(" fetched {}", fetched.asset.path.display()); println!( " verification (tier {}): {} — {}", fetched.verification.tier, if fetched.verification.passed { "PASS" } else { "FAIL" }, fetched.verification.justification ); let previously_pending = state::load_pending_version(state_dir, name); let already_pending = previously_pending.as_deref() == Some(latest.as_str()); match decide_tier_action( fetched.verification.tier, fetched.verification.passed, already_pending, ) { // Bail rather than just print-and-return: a verification failure // is exactly the kind of event a monitoring setup (systemd // OnFailure=, cron mail-on-error) needs a non-zero exit to catch — // see run_check, which treats an Err here as a failed package. TierAction::VerificationFailed => { bail!("verification failed — not publishing, not updating state"); } TierAction::Publish => { println!(" tier 1-3 pass: building + publishing"); build_and_publish(name, pkg, &latest, &fetched)?; state::save_last_version(state_dir, name, &latest)?; state::clear_pending_version(state_dir, name)?; println!(" published {name} {latest}"); notifier::notify(Event::Published { name, version: &latest, }); } TierAction::StillPending => { println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)"); } TierAction::NewlyPending => { state::save_pending_version(state_dir, name, &latest)?; match previously_pending { Some(superseded) => println!( " tier 4-6 pass: flagged for human review, superseding still-unreviewed {superseded} (`pkgwatch review` to approve {latest})" ), None => println!( " tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)" ), } notifier::notify(Event::NeedsReview { name, version: &latest, }); } } Ok(()) } struct FetchVerifyResult { version: String, asset_name: String, dest_dir: PathBuf, asset: DownloadedAsset, verification: VerificationResult, } /// Shared by the normal check loop (tier 1-3 auto-path) and `pkgwatch /// review --approve` (which re-verifies before publishing rather than /// trusting a possibly-stale flag from an earlier run). fn fetch_and_verify( client: &reqwest::blocking::Client, endpoints: &Endpoints, work_dir: &Path, name: &str, pkg: &Package, tag: &str, ) -> Result { let version = checker::version_from_tag(tag).to_string(); let asset_name = pkg.asset_pattern.replace("{version}", &version); let dest_dir = work_dir.join(name).join(tag); let api = endpoints.api(); let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?; let verification = verifier::verify( client, api, &pkg.verification, &pkg.repo, tag, &asset.path, &dest_dir, )?; Ok(FetchVerifyResult { version, asset_name, dest_dir, asset, verification, }) } fn build_and_publish( name: &str, pkg: &Package, tag: &str, fetched: &FetchVerifyResult, ) -> Result<()> { // Fail fast if the repo isn't registered in pacman.conf, before // spending several seconds on a makepkg build that would otherwise // succeed and then publish somewhere pacman never syncs from. let repo_dir = custom_repo_dir()?; publisher::ensure_registered(CUSTOM_REPO_NAME, &repo_dir)?; let build_dir = fetched.dest_dir.join("build"); let req = builder::BuildRequest { pkg_name: name, pkg, version: &fetched.version, repo: &pkg.repo, asset_name: &fetched.asset_name, download_url: &fetched.asset.download_url, artifact_path: &fetched.asset.path, }; let built = builder::build(&req, &build_dir)?; println!(" built {}", built.package_path.display()); if let Some(check) = &pkg.sanity_check { let bin_dir = built.pkgdir.join("usr/bin"); sanity::run(check, &bin_dir, &fetched.version) .with_context(|| format!("sanity check for {name} {tag}"))?; println!(" sanity check passed"); } let published = publisher::publish(&built.package_path, &repo_dir, CUSTOM_REPO_NAME)?; println!( " added to {} repo: {}", CUSTOM_REPO_NAME, published.display() ); println!( " not installed automatically — run `sudo pacman -Syu` (or `sudo pacman -S {name}`) to pick it up" ); Ok(()) } pub fn run_review(args: &[String]) -> Result<()> { let Paths { packages_dir, state_dir, work_dir, } = Paths::from_env()?; let packages = load_packages(&packages_dir)?; match args { [] => { let mut any = false; for (name, _) in &packages { if let Some(pending) = state::load_pending_version(&state_dir, name) { println!( "{name}: {pending} pending review (run `pkgwatch review {name} --approve`)" ); any = true; } } if !any { println!("no packages pending review"); } Ok(()) } [name, flag] if flag == "--approve" => { let (_, pkg) = packages.iter().find(|(n, _)| n == name).with_context(|| { format!("no package named '{name}' in {}/", packages_dir.display()) })?; let tag = state::load_pending_version(&state_dir, name) .with_context(|| format!("'{name}' has no pending review"))?; approve(&state_dir, &work_dir, name, pkg, &tag) } _ => bail!("usage: pkgwatch review [ --approve]"), } } fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> { let client = build_client()?; 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. let fetched = fetch_and_verify(&client, &endpoints, work_dir, name, pkg, tag)?; if !fetched.verification.passed { bail!( "re-verification failed on approve: {}", fetched.verification.justification ); } println!( "re-verified (tier {}): {}", fetched.verification.tier, fetched.verification.justification ); build_and_publish(name, pkg, tag, &fetched)?; state::save_last_version(state_dir, name, tag)?; state::clear_pending_version(state_dir, name)?; println!("approved and published {name} {tag}"); Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::github::GithubEndpoints; use crate::test_support::same_origin_package; #[test] fn decide_tier_action_failed_verification_overrides_everything() { assert_eq!( decide_tier_action(2, false, false), TierAction::VerificationFailed ); assert_eq!( decide_tier_action(4, false, true), TierAction::VerificationFailed ); } #[test] fn decide_tier_action_tier_1_to_3_publishes() { assert_eq!(decide_tier_action(1, true, false), TierAction::Publish); assert_eq!(decide_tier_action(2, true, false), TierAction::Publish); assert_eq!(decide_tier_action(3, true, true), TierAction::Publish); } #[test] fn decide_tier_action_tier_4_to_6_newly_pending_when_not_seen_before() { assert_eq!(decide_tier_action(4, true, false), TierAction::NewlyPending); assert_eq!(decide_tier_action(6, true, false), TierAction::NewlyPending); } #[test] fn decide_tier_action_tier_4_to_6_still_pending_when_already_flagged() { 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(), ] } /// 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 /// even though the single most security-relevant check had failed. /// Exercises the full check -> fetch -> verify path against a mocked /// GitHub (no real network), stopping before any build/publish step /// since verification failure returns before reaching those. #[test] fn process_package_returns_err_on_verification_failure() { let mut server = mockito::Server::new(); let endpoints = Endpoints::Github(GithubEndpoints { web: server.url(), api: server.url(), }); let feed = format!( r#""#, server.url() ); let _atom = server .mock("GET", "/o/r/releases.atom") .with_status(200) .with_body(feed) .create(); let _release_mocks = mock_release_with_bad_checksum(&mut server); 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); // The package's own `source` is irrelevant here: `endpoints` is // hand-built to point at the mock server, bypassing `for_package`. assert_verification_failure_is_an_error(&endpoints, &same_origin_package("")); } }