mod builder; mod checker; mod config; mod fetcher; mod github; mod hash; mod publisher; mod sanity; mod state; mod verifier; use anyhow::{Context, Result, bail}; use config::Package; use fetcher::DownloadedAsset; use github::GithubEndpoints; use std::path::{Path, PathBuf}; use verifier::VerificationResult; const PACKAGES_DIR: &str = "packages.d"; const STATE_DIR: &str = "state"; const WORK_DIR: &str = "work"; /// 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"; /// check -> fetch -> verify -> build -> sanity-check -> publish, for /// whatever is in packages.d/. Tier 1-3 passes auto-publish; tier 4-6 /// passes queue for `pkgwatch review`. See SPEC.md > Architecture. fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { None => run_check(), Some("review") => run_review(&args[1..]), Some(other) => bail!("unknown subcommand '{other}' (expected: review)"), } } 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)) } fn run_check() -> Result<()> { let client = build_client()?; let endpoints = GithubEndpoints::default(); let packages_dir = Path::new(PACKAGES_DIR); let state_dir = Path::new(STATE_DIR); let work_dir = Path::new(WORK_DIR); let packages = config::load_packages_dir(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); if let Err(err) = process_package(&client, &endpoints, state_dir, work_dir, name, pkg) { eprintln!(" error: {err:#}"); any_failed = true; } } if any_failed { bail!("one or more packages failed — see errors above"); } Ok(()) } fn process_package( client: &reqwest::blocking::Client, endpoints: &GithubEndpoints, state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, ) -> Result<()> { 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()) { 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 ); if !fetched.verification.passed { println!(" verification failed — not publishing, not updating state"); return Ok(()); } if fetched.verification.tier <= 3 { 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}"); } else if state::load_pending_version(state_dir, name).as_deref() == Some(latest.as_str()) { println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)"); } else { state::save_pending_version(state_dir, name, &latest)?; println!(" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"); } 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: &GithubEndpoints, 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 asset = fetcher::download_asset(client, endpoints, &pkg.repo, tag, &asset_name, &dest_dir)?; let verification = verifier::verify( client, endpoints, &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(()) } fn run_review(args: &[String]) -> Result<()> { let packages_dir = Path::new(PACKAGES_DIR); let state_dir = Path::new(STATE_DIR); let work_dir = Path::new(WORK_DIR); let packages = config::load_packages_dir(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 = GithubEndpoints::default(); // 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(()) }