//! 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()`. use crate::config::Package; use crate::hash; 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 /// 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 /// generality now. pub fn build(req: &BuildRequest, build_dir: &Path) -> Result { 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 /// 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 { 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)?; let sha256 = hash::sha256_hex_file(req.artifact_path)?; let install_source = if let Some(path) = &req.pkg.archive_binary_path { validate_shell_safe("archive binary path", path)?; path.clone() } else { match archive_stem(req.asset_name) { Some(stem) => format!("{stem}/{binary_name}"), None => req.asset_name.to_string(), } }; Ok(format!( "# 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, )) } /// Matches `.pkg.tar.` — not hardcoded to /// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any /// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This /// box's default happens to be `.zst`, but guessing wrong would otherwise /// report a false "makepkg failed" for a build that actually succeeded. fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result { 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; }; if file_name.starts_with(&prefix) && file_name.contains(".pkg.tar.") { return Ok(path); } } bail!( "makepkg reported success but no {prefix}*.pkg.tar.* found in {}", 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)) } /// 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 /// interpolation"). fn validate_shell_safe(field: &str, value: &str) -> Result<()> { if value.contains(['\'', '\n', '$', '`', '\\']) { bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD"); } Ok(()) } /// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no /// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the /// final package filename) and no shell metacharacters. 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); } #[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.*")); } #[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()); } #[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()); } #[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() } #[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(); let expected_sha = hash::sha256_hex(b"binary-bytes"); 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()); } #[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()); } #[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()); } }