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>
261 lines
9 KiB
Rust
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());
|
|
}
|
|
}
|