From 994cee65f585ed1bd7c3d75210ab076b77159594 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Fri, 18 Sep 2026 13:07:03 +0200 Subject: [PATCH] Add wrapper-script env var support for claude-code's self-update guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// and generates a /usr/bin/ 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 --- packages.d/claude-code.toml | 10 ++ src/builder.rs | 225 +++++++++++++++++++++++++++++++++++- src/config.rs | 48 +++++++- 3 files changed, 281 insertions(+), 2 deletions(-) diff --git a/packages.d/claude-code.toml b/packages.d/claude-code.toml index 18abf8b..5c89842 100644 --- a/packages.d/claude-code.toml +++ b/packages.d/claude-code.toml @@ -38,3 +38,13 @@ checksum_asset_pattern = "SHASUMS256.txt" [package.claude-code.sanity_check] command = "claude --version" version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)' + +# The previously-installed AUR package (claude-code 2.1.273-1) shipped these +# via a /usr/bin/claude wrapper around the real /opt/claude-code/bin/claude +# binary — almost certainly to stop Claude Code's own self-updater from +# fighting with a package manager already managing it, which applies just +# as much here. builder.rs replicates that wrapper when `env` is set: real +# binary under /usr/lib/claude-code/, generated /usr/bin/claude wrapper. +[package.claude-code.env] +DISABLE_UPDATES = "1" +DISABLE_INSTALLATION_CHECKS = "1" diff --git a/src/builder.rs b/src/builder.rs index d91bcb7..b75aa44 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -98,6 +98,13 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result { } }; + let package_body = package_body( + req.pkg_name, + binary_name, + &install_source, + req.pkg.env.as_ref(), + )?; + Ok(format!( "# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\ # edits are overwritten on the next update)\n\ @@ -113,7 +120,7 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result { sha256sums=('{sha256}')\n\ \n\ package() {{\n\ - \x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n\ + {package_body}\ }}\n", name = req.pkg_name, version = req.version, @@ -123,6 +130,47 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result { )) } +/// Builds the `package()` function body: a plain single-file install, or — +/// when `env` declares variables to export — the real binary installed +/// under `/usr/lib//` plus a generated `/usr/bin/` +/// wrapper that exports them before `exec`-ing it (see +/// `Package::env`'s doc comment for why this exists). +/// +/// The wrapper locates its sibling binary via `$(dirname "$0")` rather +/// than a hardcoded absolute path: on this box `/bin/sh` is `bash`, which +/// sets `$0` to the full resolved path when a script is found via `PATH` +/// (confirmed empirically) — so the same relative lookup resolves +/// correctly both under `sanity.rs`'s staging-directory `pkgdir` check and +/// after a real `pacman` install, with no need to special-case either. +fn package_body( + pkg_name: &str, + binary_name: &str, + install_source: &str, + env: Option<&std::collections::BTreeMap>, +) -> Result { + match env.filter(|e| !e.is_empty()) { + None => Ok(format!( + "\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n" + )), + Some(env) => { + let mut exports = String::new(); + for (key, value) in env { + validate_env_key(key)?; + validate_single_quoted_safe("env value", value)?; + exports.push_str(&format!("export {key}='{value}'\n")); + } + Ok(format!( + "\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/lib/{pkg_name}/{binary_name}\"\n\ + \x20 install -Dm755 /dev/stdin \"${{pkgdir}}/usr/bin/{binary_name}\" <<'PKGWATCH_WRAPPER'\n\ + #!/bin/sh\n\ + {exports}\ + exec \"$(dirname \"$0\")/../lib/{pkg_name}/{binary_name}\" \"$@\"\n\ + PKGWATCH_WRAPPER\n" + )) + } + } +} + /// Matches `.pkg.tar.` — not hardcoded to /// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any /// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This @@ -171,6 +219,32 @@ fn validate_shell_safe(field: &str, value: &str) -> Result<()> { Ok(()) } +/// A wrapper-script env var name must be a valid POSIX shell identifier — +/// this alone also rules out every shell metacharacter, so `export +/// {key}=...` in the generated wrapper can never be anything but a plain +/// assignment. +fn validate_env_key(key: &str) -> Result<()> { + let valid = !key.is_empty() + && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !valid { + bail!("'{key}' is not a valid environment variable name"); + } + Ok(()) +} + +/// Rejects only what can break out of a *single*-quoted shell string: a +/// literal `'` (ends the quoting early) or a newline (injects an extra +/// statement into the wrapper). Unlike `validate_shell_safe`, `$`/backtick/ +/// backslash are fine here — single quotes make them inert, and the +/// generated wrapper only ever embeds `value` inside `export key='value'`. +fn validate_single_quoted_safe(field: &str, value: &str) -> Result<()> { + if value.contains(['\'', '\n']) { + bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD"); + } + Ok(()) +} + /// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no /// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the /// final package filename) and no shell metacharacters. @@ -352,6 +426,24 @@ mod tests { toml::from_str(&toml_text).unwrap() } + fn make_package_with_env(entries: &[(&str, &str)]) -> Package { + let env_lines: String = entries + .iter() + .map(|(k, v)| format!("{k} = \"{v}\"\n")) + .collect(); + let toml_text = format!( + r#" + repo = "o/r" + asset_pattern = "x" + [verification] + method = "github-attestation" + [env] + {env_lines} + "# + ); + toml::from_str(&toml_text).unwrap() + } + #[test] fn build_rejects_unsafe_version() { let pkg = make_package(None); @@ -473,6 +565,137 @@ mod tests { assert!(generate_pkgbuild(&req).is_err()); } + #[test] + fn generate_pkgbuild_with_env_installs_via_lib_and_wrapper() { + let pkg = make_package_with_env(&[ + ("DISABLE_UPDATES", "1"), + ("DISABLE_INSTALLATION_CHECKS", "1"), + ]); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("thing.tar.gz"); + std::fs::write(&artifact_path, b"data").unwrap(); + + let req = BuildRequest { + pkg_name: "thing", + pkg: &pkg, + version: "1.0.0", + repo: "o/r", + asset_name: "thing.tar.gz", + download_url: "https://example.com/thing.tar.gz", + artifact_path: &artifact_path, + }; + let pkgbuild = generate_pkgbuild(&req).unwrap(); + + // Real binary goes under /usr/lib//, not /usr/bin directly. + assert!(pkgbuild.contains( + "install -Dm755 \"${srcdir}/thing/thing\" \"${pkgdir}/usr/lib/thing/thing\"" + )); + // Wrapper written inline via a quoted heredoc (no bash expansion at + // PKGBUILD-build time) to /usr/bin/. + assert!(pkgbuild.contains( + "install -Dm755 /dev/stdin \"${pkgdir}/usr/bin/thing\" <<'PKGWATCH_WRAPPER'" + )); + assert!(pkgbuild.contains("export DISABLE_INSTALLATION_CHECKS='1'")); + assert!(pkgbuild.contains("export DISABLE_UPDATES='1'")); + // Sorted (BTreeMap) — deterministic regardless of TOML source order. + let checks_pos = pkgbuild.find("DISABLE_INSTALLATION_CHECKS").unwrap(); + let updates_pos = pkgbuild.find("DISABLE_UPDATES").unwrap(); + assert!(checks_pos < updates_pos); + // Relative $0-based lookup, not a hardcoded absolute path — see + // package_body's doc comment for why. + assert!(pkgbuild.contains(r#"exec "$(dirname "$0")/../lib/thing/thing" "$@""#)); + } + + #[test] + fn generate_pkgbuild_without_env_keeps_single_file_install() { + // Regression guard: packages with no `env` table must keep the + // original one-line install, not gain a wrapper/lib split. + let pkg = make_package(None); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("thing.tar.gz"); + std::fs::write(&artifact_path, b"data").unwrap(); + + let req = BuildRequest { + pkg_name: "thing", + pkg: &pkg, + version: "1.0.0", + repo: "o/r", + asset_name: "thing.tar.gz", + download_url: "https://example.com/thing.tar.gz", + artifact_path: &artifact_path, + }; + let pkgbuild = generate_pkgbuild(&req).unwrap(); + + assert!(!pkgbuild.contains("PKGWATCH_WRAPPER")); + assert!(!pkgbuild.contains("/usr/lib/")); + } + + #[test] + fn generate_pkgbuild_rejects_env_value_with_single_quote() { + let pkg = make_package_with_env(&[("FOO", "bar'; touch pwned #")]); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("thing.tar.gz"); + std::fs::write(&artifact_path, b"data").unwrap(); + + let req = BuildRequest { + pkg_name: "thing", + pkg: &pkg, + version: "1.0.0", + repo: "o/r", + asset_name: "thing.tar.gz", + download_url: "https://example.com/thing.tar.gz", + artifact_path: &artifact_path, + }; + assert!(generate_pkgbuild(&req).is_err()); + } + + #[test] + fn generate_pkgbuild_rejects_invalid_env_key() { + let pkg = make_package_with_env(&[("1BAD-KEY", "value")]); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("thing.tar.gz"); + std::fs::write(&artifact_path, b"data").unwrap(); + + let req = BuildRequest { + pkg_name: "thing", + pkg: &pkg, + version: "1.0.0", + repo: "o/r", + asset_name: "thing.tar.gz", + download_url: "https://example.com/thing.tar.gz", + artifact_path: &artifact_path, + }; + assert!(generate_pkgbuild(&req).is_err()); + } + + #[test] + fn validate_env_key_accepts_underscore_and_digits_after_first_char() { + assert!(validate_env_key("DISABLE_UPDATES_2").is_ok()); + assert!(validate_env_key("_private").is_ok()); + } + + #[test] + fn validate_env_key_rejects_leading_digit() { + assert!(validate_env_key("1KEY").is_err()); + } + + #[test] + fn validate_env_key_rejects_hyphen() { + assert!(validate_env_key("MY-KEY").is_err()); + } + + #[test] + fn validate_single_quoted_safe_accepts_dollar_and_backtick() { + // Inert inside single quotes, unlike validate_shell_safe's context. + assert!(validate_single_quoted_safe("env value", "$(touch pwned)").is_ok()); + assert!(validate_single_quoted_safe("env value", "`touch pwned`").is_ok()); + } + + #[test] + fn validate_single_quoted_safe_rejects_single_quote() { + assert!(validate_single_quoted_safe("env value", "it's").is_err()); + } + #[test] fn generate_pkgbuild_rejects_download_url_with_single_quote() { let pkg = make_package(None); diff --git a/src/config.rs b/src/config.rs index 9d0cff2..9c242b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; #[derive(Debug, Deserialize)] @@ -38,6 +38,20 @@ pub struct Package { /// 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 @@ -164,6 +178,38 @@ mod tests { 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(); -- 2.45.2