pkgwatch/src/config.rs
Austin Schaefer 994cee65f5
All checks were successful
CI / build (pull_request) Successful in 47s
CI / test (pull_request) Successful in 3m31s
CI / audit (pull_request) Successful in 14s
CI / coverage (pull_request) Successful in 6m59s
Add wrapper-script env var support for claude-code's self-update guard
The previously-installed claude-code package (2.1.273-1, an AUR build)
wraps its real binary in a /usr/bin/claude script that sets
DISABLE_UPDATES=1 and DISABLE_INSTALLATION_CHECKS=1 before exec-ing
/opt/claude-code/bin/claude — almost certainly to stop Claude Code's own
self-updater from fighting with a package manager already managing it,
which applies just as much to a pkgwatch-managed install. The generated
PKGBUILD had no way to replicate that: it only ever wrote one file.

Add Package::env (a sorted BTreeMap for deterministic output). When set
and non-empty, builder.rs now installs the real binary under
/usr/lib/<pkgname>/ and generates a /usr/bin/<binary_name> wrapper that
exports the declared vars before exec-ing it, written inline via a
quoted heredoc (no bash expansion at PKGBUILD-build time). The wrapper
finds its sibling binary via $(dirname "$0") rather than a hardcoded
absolute path, since /bin/sh is bash on this box and sets $0 to the
full resolved path when found via PATH (confirmed empirically) — so the
same wrapper resolves correctly both under sanity.rs's staging-directory
pkgdir check and after a real pacman install.

Wired claude-code.toml to declare both vars. Verified end to end against
a scratch repo: build succeeds, the sanity check (which now runs through
the wrapper, not the raw binary) passes, and the built package's wrapper
genuinely exports both vars at runtime before exec-ing the real binary
(confirmed by hand, substituting the exec line for an env dump).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:07:03 +02:00

261 lines
9 KiB
Rust

//! 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<String, Package>,
}
#[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<String>,
/// 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<String>,
/// 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/<pkgname>/<binary_name>` instead of `/usr/bin` directly,
/// and a generated `/usr/bin/<binary_name>` wrapper sets these vars
/// before `exec`-ing it. Omitted or empty: no wrapper, same single-file
/// install as before.
pub env: Option<BTreeMap<String, String>>,
/// 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<SanityCheck>,
}
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<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()))?;
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<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());
}
}