'source' read like source code next to the config's source key. The trait file becomes contract.rs to avoid release_source::release_source, and the pipeline's local variables become 'host'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
489 lines
18 KiB
Rust
489 lines
18 KiB
Rust
//! 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::release_source::{self, ReleaseSource};
|
|
use crate::sanity;
|
|
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<reqwest::blocking::Client> {
|
|
Ok(reqwest::blocking::Client::builder()
|
|
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)")
|
|
.build()?)
|
|
}
|
|
|
|
fn custom_repo_dir() -> Result<PathBuf> {
|
|
// 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<Vec<(String, Package)>> {
|
|
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 = release_source::for_package(pkg).and_then(|host| {
|
|
process_package(&client, host.as_ref(), &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,
|
|
host: &dyn ReleaseSource,
|
|
state_dir: &Path,
|
|
work_dir: &Path,
|
|
name: &str,
|
|
pkg: &Package,
|
|
) -> Result<()> {
|
|
let latest = host.latest_release(client, &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, host, 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,
|
|
host: &dyn ReleaseSource,
|
|
work_dir: &Path,
|
|
name: &str,
|
|
pkg: &Package,
|
|
tag: &str,
|
|
) -> Result<FetchVerifyResult> {
|
|
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 = host.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 [<name> --approve]"),
|
|
}
|
|
}
|
|
|
|
fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> {
|
|
let client = build_client()?;
|
|
let host = release_source::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, host.as_ref(), 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::release_source::{ForgejoEndpoints, 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 `ReleaseSource::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<mockito::Mock> {
|
|
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(host: &dyn ReleaseSource, 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,
|
|
host,
|
|
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 github = GithubEndpoints {
|
|
web: server.url(),
|
|
api: server.url(),
|
|
};
|
|
let feed = format!(
|
|
r#"<feed><link rel="alternate" href="{}/o/r/releases/tag/v1.0.0"/></feed>"#,
|
|
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(&github, &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 forgejo = ForgejoEndpoints { 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: `forgejo` is
|
|
// hand-built to point at the mock server, bypassing `for_package`.
|
|
assert_verification_failure_is_an_error(&forgejo, &same_origin_package(""));
|
|
}
|
|
}
|