//! Parses `packages.d/*.toml` into typed, in-memory `Package` records. //! The only module that knows the TOML shape — everything downstream //! works with `Package`/`Verification`/`SanityCheck`, never raw TOML. use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::{BTreeMap, HashMap}; use std::path::Path; #[derive(Debug, Deserialize)] struct PackageFile { package: HashMap, } #[derive(Debug, Deserialize, Clone)] pub struct Package { pub repo: String, /// Exact GitHub release asset name (still not a glob — see /// docs/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, /// Name of the executable inside the built package, if it differs from /// the package name itself — e.g. scaleway-cli's pacman package is /// named `scaleway-cli` but its real binary is `scw` (discovered by /// checking the currently-installed extra package, not guessable from /// the repo name). Defaults to the package name when omitted. pub binary_name: Option, /// Explicit path to the binary inside the extracted archive, relative /// to `srcdir`, for archive layouts that don't match the "extracts into /// a directory named after the archive stem" convention `builder.rs` /// otherwise assumes (e.g. claude-code's tarball extracts a bare /// `claude` file with no wrapping directory, and that inner filename /// doesn't match the package name either). Only meaningful when /// `asset_pattern` names an archive; ignored for bare-binary downloads, /// where the downloaded file *is* the source path already. Defaults to /// the stem/`binary_name` convention when omitted. pub archive_binary_path: Option, /// Environment variables to export before the real binary runs, when /// the vendor's own install ships them via a wrapper script that /// `builder.rs` would otherwise not replicate — e.g. claude-code's /// prior AUR package sets `DISABLE_UPDATES=1`/ /// `DISABLE_INSTALLATION_CHECKS=1` specifically so its self-updater /// doesn't fight with a package manager already managing it, which /// applies just as much to pkgwatch-managed installs. `BTreeMap` for /// deterministic (sorted) ordering in the generated PKGBUILD. When /// present and non-empty, the real binary installs to /// `/usr/lib//` instead of `/usr/bin` directly, /// and a generated `/usr/bin/` wrapper sets these vars /// before `exec`-ing it. Omitted or empty: no wrapper, same single-file /// install as before. pub env: Option>, /// Post-build correctness check (not a security control — see /// docs/SPEC.md > Verification trust tiers). Runs `command` against the /// freshly built binary and confirms `version_regex`'s capture group /// matches the version pkgwatch believes it just built. pub sanity_check: Option, } impl Package { /// The name of the executable inside the built package: `binary_name` /// if the package declares one, else `pkg_name` itself. pub fn binary_name<'a>(&'a self, pkg_name: &'a str) -> &'a str { self.binary_name.as_deref().unwrap_or(pkg_name) } } #[derive(Debug, Deserialize, Clone)] pub struct SanityCheck { pub command: String, pub version_regex: String, } #[derive(Debug, Deserialize, Clone)] #[serde(tag = "method", rename_all = "kebab-case")] pub enum Verification { /// Tier 4: proves transport integrity only, not authorship. See /// docs/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 /// docs/SPEC.md > Scaling to many packages), keyed by package name. pub fn load_packages_dir(dir: &Path) -> Result> { 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()))?; let file: PackageFile = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; out.extend(file.package); } Ok(out) } #[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_env_table_as_sorted_map() { let dir = tempfile::tempdir().unwrap(); write( dir.path(), "pkg.toml", r#" [package.pkg] repo = "o/r" asset_pattern = "pkg.tar.gz" [package.pkg.verification] method = "github-attestation" [package.pkg.env] DISABLE_UPDATES = "1" DISABLE_INSTALLATION_CHECKS = "1" "#, ); let packages = load_packages_dir(dir.path()).unwrap(); let env = packages[0].1.env.as_ref().unwrap(); let entries: Vec<(&String, &String)> = env.iter().collect(); assert_eq!( entries, vec![ (&"DISABLE_INSTALLATION_CHECKS".to_string(), &"1".to_string()), (&"DISABLE_UPDATES".to_string(), &"1".to_string()), ] ); } #[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 = 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()); } }