pkgwatch/src/builder.rs

517 lines
20 KiB
Rust
Raw Normal View History

Add ARCHITECTURE.md and apply it to this PR's code Researched current industry practice on code organization/maintainability (Ousterhout's deep modules and information hiding, package-by-feature vs. package-by-layer, functional-core/imperative-shell testability, tech-debt prevention via ADR-equivalent inline rationale) and wrote it into ARCHITECTURE.md as a set of concrete, project-specific rules rather than a generic essay — each principle cites a real example already in this codebase or fixed by this commit. Cross-linked from SPEC.md, which stays about product design, not code organization. Applied it to this PR's own code: - Pulled process_package/fetch_and_verify/build_and_publish/run_review/ approve out of main.rs into a new pipeline.rs. main.rs's own main() had grown to 278 lines and zero tests by treating "it's just the entry point" as an excuse to skip separating logic from wiring; now main.rs is argv dispatch only. - Extracted decide_tier_action as a pure function (verification outcome + pending-state -> what to do), replacing dispatch logic that was previously inlined into a function that also made the real network/ build calls. Four unit tests, no I/O, covering all four outcomes. - Added a `//!` module doc comment to every file touched in this branch, each stating that module's one job in a sentence, per the "deep modules" principle the spec argues for. Coverage's reported total drops (94% -> 78%) because pipeline.rs is deliberately NOT excluded from it the way main.rs is, even though it's mostly the same kind of untestable I/O orchestration — excluding it would hide decide_tier_action's real unit-test coverage along with the untested parts. Noted inline in Makefile.toml/ci.yml so the number doesn't look like a quality regression at a glance. Also added a project reference memory pointing at ARCHITECTURE.md rather than duplicating its content there, per this session's own memory-hygiene rules (architecture/conventions are derivable from the repo and shouldn't be duplicated somewhere that can go stale). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:42:18 +00:00
//! Turns an already-downloaded, already-verified artifact into a built
//! pacman package: generates a PKGBUILD, then runs `makepkg`. Hides all
//! PKGBUILD templating and upstream-string validation behind `build()`.
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
use crate::config::Package;
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
use crate::hash;
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Archive extensions `makepkg` auto-extracts before `package()` runs.
/// Longest-first so `.tar.gz` isn't shadowed by a hypothetical `.gz` entry.
const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tgz", ".zip"];
/// Everything needed to generate and build a PKGBUILD for one release.
pub struct BuildRequest<'a> {
pub pkg_name: &'a str,
pub pkg: &'a Package,
pub version: &'a str,
pub repo: &'a str,
pub asset_name: &'a str,
pub download_url: &'a str,
pub artifact_path: &'a Path,
}
pub struct BuildResult {
/// The built `.pkg.tar.zst`, ready for `publisher::publish`.
pub package_path: PathBuf,
/// `makepkg`'s package staging directory (`$pkgdir`), still present
/// after a successful build — lets `sanity` exercise the freshly built
/// binary without installing it system-wide first.
pub pkgdir: PathBuf,
}
/// Generates a PKGBUILD around an already-downloaded, already-verified
/// 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 shapes
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
/// currently-tracked packages actually need: a bare-binary download
/// (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
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
/// generality now.
pub fn build(req: &BuildRequest, build_dir: &Path) -> Result<BuildResult> {
let pkgbuild = generate_pkgbuild(req)?;
std::fs::create_dir_all(build_dir)
.with_context(|| format!("creating build dir {}", build_dir.display()))?;
std::fs::write(build_dir.join("PKGBUILD"), pkgbuild)?;
// makepkg looks for the source file by its declared name next to
// PKGBUILD; pre-seed it with the copy pkgwatch already downloaded and
// verified so makepkg's own sha256 check passes without re-fetching
// from the network (and without trusting the network a second time).
std::fs::copy(req.artifact_path, build_dir.join(req.asset_name))?;
let status = Command::new("makepkg")
.args(["--noconfirm", "--force"])
.current_dir(build_dir)
.status()
.context("running makepkg (is base-devel installed?)")?;
if !status.success() {
bail!("makepkg failed for {} {}", req.pkg_name, req.version);
}
let package_path = find_built_package(build_dir, req.pkg_name, req.version)?;
let pkgdir = build_dir.join("pkg").join(req.pkg_name);
Ok(BuildResult {
package_path,
pkgdir,
})
}
/// Builds the PKGBUILD text for `req`, validating every upstream-controlled
/// string first (see docs/SPEC.md > Architecture > Builder: "strict validation
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
/// on any upstream-controlled string ... never unescaped interpolation").
/// Pure and side-effect-free so it's testable without invoking `makepkg`.
fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
validate_pkgname(req.pkg_name)?;
validate_pkgver(req.version)?;
validate_shell_safe("asset name", req.asset_name)?;
validate_shell_safe("download url", req.download_url)?;
validate_shell_safe("repo", req.repo)?;
let binary_name = req.pkg.binary_name(req.pkg_name);
validate_pkgname(binary_name)?;
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
let sha256 = hash::sha256_hex_file(req.artifact_path)?;
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
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(),
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
};
Ok(format!(
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
# edits are overwritten on the next update)\n\
pkgname='{name}'\n\
pkgver='{version}'\n\
pkgrel=1\n\
pkgdesc='{repo} release {version}, packaged by pkgwatch'\n\
arch=('x86_64')\n\
url='https://github.com/{repo}'\n\
license=('unknown')\n\
options=('!strip')\n\
source=('{asset}::{url}')\n\
sha256sums=('{sha256}')\n\
\n\
package() {{\n\
\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n\
}}\n",
name = req.pkg_name,
version = req.version,
repo = req.repo,
asset = req.asset_name,
url = req.download_url,
))
}
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
/// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
/// box's default happens to be `.zst`, but guessing wrong would otherwise
/// report a false "makepkg failed" for a build that actually succeeded.
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result<PathBuf> {
let prefix = format!("{pkg_name}-{version}-");
for entry in std::fs::read_dir(build_dir)? {
let path = entry?.path();
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
if file_name.starts_with(&prefix) && file_name.contains(".pkg.tar.") {
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
return Ok(path);
}
}
bail!(
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
"makepkg reported success but no {prefix}*.pkg.tar.* found in {}",
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
build_dir.display()
)
}
/// Strips a recognized archive extension, returning the resulting stem —
/// the directory name `makepkg` extracts a same-named tarball into, by
/// the convention every currently-tracked tarball-shaped package follows.
/// `None` means the asset is a bare binary download (no extraction).
fn archive_stem(asset_name: &str) -> Option<&str> {
ARCHIVE_EXTENSIONS
.iter()
.find_map(|ext| asset_name.strip_suffix(ext))
}
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
/// Rejects characters that are dangerous in *either* quoting style the
/// PKGBUILD template uses: a single quote breaks out of the single-quoted
/// fields (`pkgname`, `sha256sums`, ...); `$`, a backtick, or a backslash
/// are still live inside the double-quoted `install()` line, where
/// `asset_name` (via `install_source`) and `binary_name` end up embedded
/// so `${srcdir}`/`${pkgdir}` can expand. A single check covering both
/// contexts is safer than trying to remember which fields land in which
/// quoting style. See docs/SPEC.md > Architecture > Builder ("never unescaped
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
/// interpolation").
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
if value.contains(['\'', '\n', '$', '`', '\\']) {
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
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.
fn validate_pkgver(version: &str) -> Result<()> {
let valid = !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+'));
if !valid {
bail!("'{version}' is not a valid pacman pkgver (only [A-Za-z0-9._+] allowed)");
}
Ok(())
}
/// A pacman package/binary name may only contain lowercase alphanumerics
/// plus `@ . _ + -`.
fn validate_pkgname(name: &str) -> Result<()> {
let valid = !name.is_empty()
&& name.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '@' | '.' | '_' | '+' | '-')
});
if !valid {
bail!("'{name}' is not a valid pacman package name");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_stem_strips_known_extensions() {
assert_eq!(
archive_stem("uv-x86_64-unknown-linux-gnu.tar.gz"),
Some("uv-x86_64-unknown-linux-gnu")
);
assert_eq!(archive_stem("thing.zip"), Some("thing"));
}
#[test]
fn archive_stem_none_for_bare_binary() {
assert_eq!(archive_stem("scaleway-cli_2.62.0_linux_amd64"), None);
}
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
#[test]
fn find_built_package_matches_default_zst_extension() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"));
}
#[test]
fn find_built_package_matches_non_default_pkgext() {
// A box with PKGEXT='.pkg.tar.xz' in makepkg.conf shouldn't report
// a false failure just because this crate's default assumption
// (.zst) doesn't match.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"), b"").unwrap();
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"));
}
#[test]
fn find_built_package_ignores_non_matching_prefix() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("other-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
assert!(find_built_package(dir.path(), "uv", "0.12.15").is_err());
}
#[test]
fn find_built_package_errors_with_clear_message_when_nothing_matches() {
let dir = tempfile::tempdir().unwrap();
let err = find_built_package(dir.path(), "uv", "0.12.15").unwrap_err();
assert!(err.to_string().contains("uv-0.12.15-"));
assert!(err.to_string().contains(".pkg.tar.*"));
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
#[test]
fn validate_pkgver_accepts_dotted_version() {
assert!(validate_pkgver("2.62.0").is_ok());
}
#[test]
fn validate_pkgver_rejects_hyphen() {
assert!(validate_pkgver("2.62.0-dbg1").is_err());
}
#[test]
fn validate_pkgver_rejects_shell_metacharacters() {
assert!(validate_pkgver("2.62.0; rm -rf /").is_err());
}
#[test]
fn validate_pkgver_rejects_empty() {
assert!(validate_pkgver("").is_err());
}
#[test]
fn validate_pkgname_accepts_hyphenated_name() {
assert!(validate_pkgname("scaleway-cli").is_ok());
}
#[test]
fn validate_pkgname_rejects_uppercase() {
assert!(validate_pkgname("Scaleway-CLI").is_err());
}
#[test]
fn validate_shell_safe_rejects_single_quote() {
assert!(validate_shell_safe("asset name", "thing'; touch pwned #.tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_newline() {
assert!(validate_shell_safe("download url", "https://example.com/a\nb").is_err());
}
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
#[test]
fn validate_shell_safe_rejects_dollar_sign() {
// asset_name lands inside a double-quoted string via
// install_source — $() command substitution is still live there
// even though single-quote breakout isn't.
assert!(validate_shell_safe("asset name", "thing$(touch pwned).tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_backtick() {
assert!(validate_shell_safe("asset name", "thing`touch pwned`.tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_backslash() {
assert!(validate_shell_safe("asset name", "thing\\$(touch pwned).tar.gz").is_err());
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
#[test]
fn validate_shell_safe_accepts_normal_url() {
assert!(validate_shell_safe("download url", "https://example.com/a/b.tar.gz").is_ok());
}
fn make_package(binary_name: Option<&str>) -> Package {
let toml_text = match binary_name {
Some(bin) => format!(
r#"
repo = "o/r"
asset_pattern = "x"
binary_name = "{bin}"
[verification]
method = "github-attestation"
"#
),
None => r#"
repo = "o/r"
asset_pattern = "x"
[verification]
method = "github-attestation"
"#
.to_string(),
};
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()
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
#[test]
fn build_rejects_unsafe_version() {
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-dbg1",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let build_dir = dir.path().join("build");
assert!(build(&req, &build_dir).is_err());
}
#[test]
fn generate_pkgbuild_bare_binary_installs_under_binary_name_override() {
let pkg = make_package(Some("scw"));
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("scaleway-cli_2.62.0_linux_amd64");
std::fs::write(&artifact_path, b"binary-bytes").unwrap();
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
let expected_sha = hash::sha256_hex(b"binary-bytes");
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
let req = BuildRequest {
pkg_name: "scaleway-cli",
pkg: &pkg,
version: "2.62.0",
repo: "scaleway/scaleway-cli",
asset_name: "scaleway-cli_2.62.0_linux_amd64",
download_url: "https://github.com/scaleway/scaleway-cli/releases/download/v2.62.0/scaleway-cli_2.62.0_linux_amd64",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(pkgbuild.contains("pkgname='scaleway-cli'"));
assert!(pkgbuild.contains("pkgver='2.62.0'"));
assert!(pkgbuild.contains(&format!("sha256sums=('{expected_sha}')")));
// Bare binary (no archive extension) — installed straight from
// srcdir under the overridden binary name, not the pkgname.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/scaleway-cli_2.62.0_linux_amd64\" \"${pkgdir}/usr/bin/scw\""
));
}
#[test]
fn generate_pkgbuild_tarball_installs_from_extracted_stem_dir() {
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("uv-x86_64-unknown-linux-gnu.tar.gz");
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
let req = BuildRequest {
pkg_name: "uv",
pkg: &pkg,
version: "0.12.15",
repo: "astral-sh/uv",
asset_name: "uv-x86_64-unknown-linux-gnu.tar.gz",
download_url: "https://github.com/astral-sh/uv/releases/download/0.12.15/uv-x86_64-unknown-linux-gnu.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
// No binary_name override — pkgname doubles as the binary name,
// and makepkg extracts the tarball into a same-named directory.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/uv-x86_64-unknown-linux-gnu/uv\" \"${pkgdir}/usr/bin/uv\""
));
}
#[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());
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
#[test]
fn generate_pkgbuild_rejects_download_url_with_single_quote() {
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/x'; touch pwned #.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
Fix issues from code review: shell-escaping gap, exit code, and more A single-agent code review of this branch's diff (builder/pipeline/ publisher/sanity/hash + main/config/fetcher/state/verifier changes) found six real issues, all fixed here: - builder.rs: validate_shell_safe only rejected a literal single quote and newline, written for the single-quoted PKGBUILD fields. But asset_name (via install_source) and binary_name land in the install() line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can expand — where $, backtick, and backslash are still live. Not currently exploitable (the one variable component, version, is already independently constrained by validate_pkgver's strict charset), but a latent gap relying on that coincidence rather than the validator actually covering its real use context. Widened the reject-list to cover both quoting styles, added regression tests including one at the generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to match. - pipeline.rs: a verification failure returned Ok(()) from process_package, so run_check never counted it as a failure and the process exited 0 even on a failed cryptographic/attestation check — exactly the event a monitoring setup (systemd OnFailure=, cron mail-on-error) most needs a non-zero exit to catch. Now bails, which run_check already treats as a package failure. Added an integration test against a mocked GitHub server exercising this exact path. - builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a box with a different PKGEXT in makepkg.conf would report a false "makepkg failed" for a build that actually succeeded. Widened to match any .pkg.tar.* compression. Added direct unit tests (it had none). - pipeline.rs: a newer tier 4-6 version silently overwrote a still- unreviewed older pending version with no indication anything was superseded. Now says so explicitly. - hash.rs: builder/verifier each read a whole downloaded artifact into memory via std::fs::read just to hash it, doubling peak memory for no reason since the file's already on disk. Added sha256_hex_file, streamed in fixed-size chunks; both callers switched to it. - Deduplicated two near-identical test-only "write an executable shell script" helpers (publisher.rs, sanity.rs) into a shared src/test_support.rs. 75 tests (was 63), cargo make ci clean. Re-verified end to end against the real astral-sh/uv release after all six fixes — build, sanity check, and publish into a scratch repo all still succeed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:06:40 +00:00
#[test]
fn generate_pkgbuild_rejects_asset_name_with_command_substitution() {
// Regression test: asset_name feeds install_source, which is
// embedded in the double-quoted install() line, not a
// single-quoted field — a single-quote-only check would miss this.
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$(touch pwned).tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
Close the loop: build, sanity-check, and publish for the first time Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD generation + makepkg (builder.rs), a post-build version sanity check (sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs for both the tier 1-3 auto-publish path and a new tier 4-6 review queue (`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via state::{load,save,clear}_pending_version, tracked separately from last-published-version since approving one release isn't a standing auto-publish grant for future ones). Publishing targets an existing, already-registered local pacman repo (~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather than one pkgwatch invents — found already in real use for a hand-packaged AppImage, which resolves SPEC's open question on where the repo lives without pkgwatch ever touching pacman.conf. Publishing stops at `repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is left to the operator, not run automatically. Getting a real second package (scaleway-cli, tier 4) through the new pipeline immediately surfaced a real gap: its pacman package is named `scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql` against the currently-installed extra package) — without a way to declare that, the build would install alongside extra's package under the wrong name instead of shadowing it. Added `Package::binary_name` (config.rs) to cover it. Every upstream-controlled string (version, asset name, download URL) is validated before it touches generated shell content in the PKGBUILD template — rejects anything containing a single quote or newline, since values are embedded in single-quoted bash strings. Verified for real, end to end: uv (tier 2) auto-built and published against the real astral-sh/uv release with no human step; scaleway-cli (tier 4) queued for review, then approved via `pkgwatch review scaleway-cli --approve`, which re-verified, built, and published it — confirmed the built package contains exactly usr/bin/scw. Both landed in the real custom repo's database. Left scaleway-cli's real-repo review pending rather than approving it myself: the tier 4-6 gate exists for a human judgment call, not the agent's. 69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
}