From 0044eda532a4376e42b948f9de37c3a47718ae58 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Fri, 18 Sep 2026 12:46:28 +0200 Subject: [PATCH] Add a third builder shape for archives with no wrapping directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging master's claude-code.toml onto this branch surfaced a real gap: builder.rs only knew "bare binary download" and "tarball extracting into a same-named directory" (uv). claude-code's tarball extracts a bare `claude` file with no wrapping directory, and that inner filename doesn't match the package name either — makepkg's package() failed with "cannot stat .../claude-linux-x64/claude-code" (confirmed by actually running the build). Add Package::archive_binary_path, an explicit override for the in-archive path builder.rs installs from, used verbatim when present instead of the stem/binary_name convention. Set binary_name = "claude" too, matching the box's actual command name (/opt/claude-code/bin/claude) rather than the claude-code package name. Also added a sanity_check block (claude --version), matching uv's pattern, confirmed against the real built binary's output ("2.1.276 (Claude Code)"). Verified end to end against the real repo with PKGWATCH_REPO_DIR pointed at a scratch dir: check -> fetch -> verify -> review --approve -> build -> sanity-check -> publish all pass for claude-code v2.1.276. Co-Authored-By: Claude Sonnet 5 --- packages.d/claude-code.toml | 14 +++++++ src/builder.rs | 83 ++++++++++++++++++++++++++++++++++--- src/config.rs | 10 +++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages.d/claude-code.toml b/packages.d/claude-code.toml index 0409e2d..18abf8b 100644 --- a/packages.d/claude-code.toml +++ b/packages.d/claude-code.toml @@ -16,11 +16,25 @@ # no `{version}` placeholder is needed, same as uv's config. Tracking the # glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl # variant, to match this machine. +# +# Archive shape doesn't match uv's or scaleway-cli's: the tarball extracts a +# bare `claude` file with no wrapping directory (confirmed via `tar tzvf` +# against the real v2.1.276 asset) — hence archive_binary_path below (see +# builder.rs's third shape). binary_name is also set explicitly to `claude` +# (the real upstream command name, not the `claude-code` package name) so +# the installed binary matches what this box already invokes as `claude` +# (see /opt/claude-code/bin/claude). [package.claude-code] repo = "anthropics/claude-code" asset_pattern = "claude-linux-x64.tar.gz" +binary_name = "claude" +archive_binary_path = "claude" [package.claude-code.verification] method = "same-origin-sha256" checksum_asset_pattern = "SHASUMS256.txt" + +[package.claude-code.sanity_check] +command = "claude --version" +version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)' diff --git a/src/builder.rs b/src/builder.rs index 8ce0021..d91bcb7 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -36,10 +36,12 @@ pub struct BuildResult { /// artifact, then runs `makepkg` in `build_dir`. /// /// Deliberately one fixed "prebuilt binary" shape, not a templating engine -/// — see docs/SPEC.md > Scaling > Template reuse. Covers the two shapes the two +/// — see docs/SPEC.md > Scaling > Template reuse. Covers the shapes /// currently-tracked packages actually need: a bare-binary download -/// (scaleway-cli) and a tarball containing a same-named directory (uv). -/// Extend when a third real shape shows up rather than guessing at +/// (scaleway-cli), a tarball containing a same-named directory (uv), and a +/// tarball with no wrapping directory at all whose inner filename doesn't +/// match the package name (claude-code — see `Package::archive_binary_path`). +/// Extend when a fourth real shape shows up rather than guessing at /// generality now. pub fn build(req: &BuildRequest, build_dir: &Path) -> Result { let pkgbuild = generate_pkgbuild(req)?; @@ -86,9 +88,14 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result { let sha256 = hash::sha256_hex_file(req.artifact_path)?; - let install_source = match archive_stem(req.asset_name) { - Some(stem) => format!("{stem}/{binary_name}"), - None => req.asset_name.to_string(), + let install_source = if let Some(path) = &req.pkg.archive_binary_path { + validate_shell_safe("archive binary path", path)?; + path.clone() + } else { + match archive_stem(req.asset_name) { + Some(stem) => format!("{stem}/{binary_name}"), + None => req.asset_name.to_string(), + } }; Ok(format!( @@ -328,6 +335,23 @@ mod tests { toml::from_str(&toml_text).unwrap() } + fn make_package_with_archive_binary_path( + binary_name: &str, + archive_binary_path: &str, + ) -> Package { + let toml_text = format!( + r#" + repo = "o/r" + asset_pattern = "x" + binary_name = "{binary_name}" + archive_binary_path = "{archive_binary_path}" + [verification] + method = "github-attestation" + "# + ); + toml::from_str(&toml_text).unwrap() + } + #[test] fn build_rejects_unsafe_version() { let pkg = make_package(None); @@ -402,6 +426,53 @@ mod tests { )); } + #[test] + fn generate_pkgbuild_flat_archive_installs_from_archive_binary_path_override() { + // claude-code's shape: a tarball with no wrapping directory, whose + // inner filename ("claude") doesn't match the package name + // ("claude-code") — neither existing shape (stem/binary_name, or + // bare-binary-no-archive) fits, hence the explicit override. + let pkg = make_package_with_archive_binary_path("claude-code", "claude"); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("claude-linux-x64.tar.gz"); + std::fs::write(&artifact_path, b"tarball-bytes").unwrap(); + + let req = BuildRequest { + pkg_name: "claude-code", + pkg: &pkg, + version: "2.1.276", + repo: "anthropics/claude-code", + asset_name: "claude-linux-x64.tar.gz", + download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz", + artifact_path: &artifact_path, + }; + let pkgbuild = generate_pkgbuild(&req).unwrap(); + + assert!( + pkgbuild + .contains("install -Dm755 \"${srcdir}/claude\" \"${pkgdir}/usr/bin/claude-code\"") + ); + } + + #[test] + fn generate_pkgbuild_rejects_archive_binary_path_with_command_substitution() { + let pkg = make_package_with_archive_binary_path("claude-code", "claude$(touch pwned)"); + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("claude-linux-x64.tar.gz"); + std::fs::write(&artifact_path, b"data").unwrap(); + + let req = BuildRequest { + pkg_name: "claude-code", + pkg: &pkg, + version: "2.1.276", + repo: "anthropics/claude-code", + asset_name: "claude-linux-x64.tar.gz", + download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz", + artifact_path: &artifact_path, + }; + assert!(generate_pkgbuild(&req).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 9a61e95..9d0cff2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,16 @@ pub struct Package { /// 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, /// 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