pkgwatch/src/config.rs

177 lines
5.2 KiB
Rust
Raw Normal View History

use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct PackageFile {
package: HashMap<String, Package>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Package {
pub repo: String,
/// Exact GitHub release asset name (still not a glob — see
/// SPEC.md > Architecture > Fetcher), optionally containing a
/// `{version}` placeholder for projects whose asset names embed the
/// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via
/// `checker::version_from_tag` before matching.
pub asset_pattern: String,
pub verification: Verification,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "method", rename_all = "kebab-case")]
pub enum Verification {
/// Tier 4: proves transport integrity only, not authorship. See
/// SPEC.md > Verification trust tiers. `checksum_asset_pattern` may
/// also contain a `{version}` placeholder, same as `asset_pattern`.
SameOriginSha256 { checksum_asset_pattern: String },
/// Tier 2: GitHub build-provenance attestation, verified via `gh
/// attestation verify` rather than reimplementing sigstore in Rust.
GithubAttestation,
}
impl Verification {
pub fn tier(&self) -> u8 {
match self {
Verification::SameOriginSha256 { .. } => 4,
Verification::GithubAttestation => 2,
}
}
}
/// Loads every `*.toml` file in `dir` (the `packages.d/` layout from
/// SPEC.md > Scaling to many packages), keyed by package name.
pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
Set up project tooling to match the rest of ~/dev's Rust fleet Surveyed sporah/doubleo7/feedsignal/uy-immigration-watcher/notif-picker for conventions and replicated the current dominant pattern rather than inventing a new one: - Forgejo CI (.forgejo/workflows/ci.yml): build/test/audit jobs on the rust-ci runner label, cargo+sccache caching, cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo audit. Matches sporah/doubleo7/feedsignal/uy-immigration-watcher; notif-picker's docker-label/manual-toolchain-install variant looks like an earlier iteration superseded by this one. - Makefile.toml with format/format-check/lint/test/audit/build tasks and a `ci` task chaining them — copied from notif-picker's clean version, the only project that had this pattern. `cargo make ci` now runs the same checks locally that CI runs. - Explicit empty [workspace] in Cargo.toml (doubleo7's pattern) so a nested git-worktree checkout can't accidentally inherit an ancestor directory's workspace manifest. - rustfmt: no rustfmt.toml, matching every sibling project — default style is the established convention here, not an oversight. New for this fleet, since nothing else in ~/dev has it: a git-native pre-commit hook (.githooks/pre-commit, activated via `cargo make install-hooks` / `git config core.hooksPath .githooks`) that runs `cargo fmt` and re-stages whatever it reformats. Chose git's native hooksPath over the pre-commit(.com) framework or cargo-husky — no extra runtime dependency, hook is tracked and shareable, and nothing else here needs Python. Kept to formatting only; clippy/audit stay in CI, which already covers them and can run heavier checks than a commit hook should. Fixed one clippy finding (useless format! in checker.rs) and reformatted the existing code to match the now-enforced default rustfmt style. `cargo make ci` passes clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2FEut5tVMNjeVjqhgVZbr
2026-09-11 08:13:29 +00:00
let file: PackageFile =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
out.extend(file.package);
}
Ok(out)
}
Add shift-left quality gates: cognitive complexity, coverage, dependency fix Adds two new gates to the existing format/lint/test/audit pipeline (Makefile.toml `cargo make ci`, .forgejo/workflows/ci.yml): - Cognitive complexity via clippy's nursery cognitive_complexity lint (clippy.toml, threshold 15), scoped to --bins so test code's naturally higher branch count doesn't get gated. Went with this over the closest real cyclomatic-complexity tool (rust-code-analysis-cli) because that crate hasn't shipped a release since Jan 2023. - Test coverage via cargo-llvm-cov, chosen over cargo-tarpaulin for friendlier behavior in containerized/dind CI (no ptrace). Report-only for now (no --fail-under-lines) since a real threshold needs real usage data first — see below. main.rs is excluded: it's orchestration glue exercised by the real end-to-end `cargo run`, not unit tests. Getting both gates running required writing pkgwatch's first tests (previously zero). To make the GitHub-facing modules unit-testable without hitting real github.com/api.github.com, added `GithubEndpoints` (src/github.rs) so checker/fetcher/verifier take injectable base URLs, and added mockito + tempfile as dev-dependencies. Result: 27 tests, 94% region / 96% line coverage excluding main.rs. Also: cargo audit (now wired into `cargo make ci`) immediately caught a real, currently-open advisory (RUSTSEC-2026-0285, published days ago) in the transitive rustls dependency — bumped 0.23.44 -> 0.23.45 to clear it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 07:16:14 +00:00
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, contents: &str) {
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn tier_matches_verification_method() {
assert_eq!(
Verification::SameOriginSha256 {
checksum_asset_pattern: "x".to_string()
}
.tier(),
4
);
assert_eq!(Verification::GithubAttestation.tier(), 2);
}
#[test]
fn loads_same_origin_sha256_package() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"pkg.toml",
r#"
[package.pkg]
repo = "o/r"
asset_pattern = "pkg_{version}_linux_amd64"
[package.pkg.verification]
method = "same-origin-sha256"
checksum_asset_pattern = "SHA256SUMS"
"#,
);
let packages = load_packages_dir(dir.path()).unwrap();
assert_eq!(packages.len(), 1);
let (name, pkg) = &packages[0];
assert_eq!(name, "pkg");
assert_eq!(pkg.repo, "o/r");
assert_eq!(pkg.asset_pattern, "pkg_{version}_linux_amd64");
assert_eq!(pkg.verification.tier(), 4);
}
#[test]
fn loads_github_attestation_package() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"pkg.toml",
r#"
[package.pkg]
repo = "o/r"
asset_pattern = "pkg-x86_64.tar.gz"
[package.pkg.verification]
method = "github-attestation"
"#,
);
let packages = load_packages_dir(dir.path()).unwrap();
assert_eq!(packages[0].1.verification.tier(), 2);
}
#[test]
fn loads_multiple_files_and_ignores_non_toml() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"a.toml",
r#"
[package.a]
repo = "o/a"
asset_pattern = "a.tar.gz"
[package.a.verification]
method = "github-attestation"
"#,
);
write(
dir.path(),
"b.toml",
r#"
[package.b]
repo = "o/b"
asset_pattern = "b.tar.gz"
[package.b.verification]
method = "github-attestation"
"#,
);
write(dir.path(), "README.md", "not a package file");
let mut names: Vec<String> = load_packages_dir(dir.path())
.unwrap()
.into_iter()
.map(|(name, _)| name)
.collect();
names.sort();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn errors_on_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "bad.toml", "not valid toml [[[");
assert!(load_packages_dir(dir.path()).is_err());
}
#[test]
fn empty_dir_yields_empty_list() {
let dir = tempfile::tempdir().unwrap();
assert!(load_packages_dir(dir.path()).unwrap().is_empty());
}
}