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>
This commit is contained in:
parent
d96b7a32f6
commit
994cee65f5
3 changed files with 281 additions and 2 deletions
|
|
@ -38,3 +38,13 @@ checksum_asset_pattern = "SHASUMS256.txt"
|
||||||
[package.claude-code.sanity_check]
|
[package.claude-code.sanity_check]
|
||||||
command = "claude --version"
|
command = "claude --version"
|
||||||
version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)'
|
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"
|
||||||
|
|
|
||||||
225
src/builder.rs
225
src/builder.rs
|
|
@ -98,6 +98,13 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let package_body = package_body(
|
||||||
|
req.pkg_name,
|
||||||
|
binary_name,
|
||||||
|
&install_source,
|
||||||
|
req.pkg.env.as_ref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
|
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
|
||||||
# edits are overwritten on the next update)\n\
|
# edits are overwritten on the next update)\n\
|
||||||
|
|
@ -113,7 +120,7 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
||||||
sha256sums=('{sha256}')\n\
|
sha256sums=('{sha256}')\n\
|
||||||
\n\
|
\n\
|
||||||
package() {{\n\
|
package() {{\n\
|
||||||
\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n\
|
{package_body}\
|
||||||
}}\n",
|
}}\n",
|
||||||
name = req.pkg_name,
|
name = req.pkg_name,
|
||||||
version = req.version,
|
version = req.version,
|
||||||
|
|
@ -123,6 +130,47 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the `package()` function body: a plain single-file install, or —
|
||||||
|
/// when `env` declares variables to export — the real binary installed
|
||||||
|
/// under `/usr/lib/<pkgname>/` plus a generated `/usr/bin/<binary_name>`
|
||||||
|
/// 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<String, String>>,
|
||||||
|
) -> Result<String> {
|
||||||
|
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 `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
|
/// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
|
||||||
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
|
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
|
||||||
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
|
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
|
||||||
|
|
@ -171,6 +219,32 @@ fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
|
||||||
Ok(())
|
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
|
/// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no
|
||||||
/// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the
|
/// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the
|
||||||
/// final package filename) and no shell metacharacters.
|
/// final package filename) and no shell metacharacters.
|
||||||
|
|
@ -352,6 +426,24 @@ mod tests {
|
||||||
toml::from_str(&toml_text).unwrap()
|
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]
|
#[test]
|
||||||
fn build_rejects_unsafe_version() {
|
fn build_rejects_unsafe_version() {
|
||||||
let pkg = make_package(None);
|
let pkg = make_package(None);
|
||||||
|
|
@ -473,6 +565,137 @@ mod tests {
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
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/<pkgname>/, 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/<binary_name>.
|
||||||
|
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]
|
#[test]
|
||||||
fn generate_pkgbuild_rejects_download_url_with_single_quote() {
|
fn generate_pkgbuild_rejects_download_url_with_single_quote() {
|
||||||
let pkg = make_package(None);
|
let pkg = make_package(None);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -38,6 +38,20 @@ pub struct Package {
|
||||||
/// where the downloaded file *is* the source path already. Defaults to
|
/// where the downloaded file *is* the source path already. Defaults to
|
||||||
/// the stem/`binary_name` convention when omitted.
|
/// the stem/`binary_name` convention when omitted.
|
||||||
pub archive_binary_path: Option<String>,
|
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
|
/// Post-build correctness check (not a security control — see
|
||||||
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the
|
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the
|
||||||
/// freshly built binary and confirms `version_regex`'s capture group
|
/// freshly built binary and confirms `version_regex`'s capture group
|
||||||
|
|
@ -164,6 +178,38 @@ mod tests {
|
||||||
assert_eq!(packages[0].1.verification.tier(), 2);
|
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]
|
#[test]
|
||||||
fn loads_multiple_files_and_ignores_non_toml() {
|
fn loads_multiple_files_and_ignores_non_toml() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue