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;
|
|
|
|
|
use crate::hash::sha256_hex;
|
|
|
|
|
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 SPEC.md > Scaling > Template reuse. Covers the two shapes the two
|
|
|
|
|
/// currently-tracked packages actually need: a bare-binary download
|
|
|
|
|
/// (scaleway-cli) and a tarball containing a same-named directory (uv).
|
|
|
|
|
/// Extend when a third real shape shows up rather than guessing at
|
|
|
|
|
/// 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 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<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)?;
|
|
|
|
|
|
|
|
|
|
let artifact_data = std::fs::read(req.artifact_path)
|
|
|
|
|
.with_context(|| format!("reading {}", req.artifact_path.display()))?;
|
|
|
|
|
let sha256 = sha256_hex(&artifact_data);
|
|
|
|
|
|
|
|
|
|
let install_source = 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,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
if file_name.starts_with(&prefix) && file_name.ends_with(".pkg.tar.zst") {
|
|
|
|
|
return Ok(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bail!(
|
|
|
|
|
"makepkg reported success but no {prefix}*.pkg.tar.zst 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 a single quote or newline: both would let upstream-controlled
|
|
|
|
|
/// text (asset names, download URLs) break out of the single-quoted bash
|
|
|
|
|
/// strings the PKGBUILD template embeds them in. See SPEC.md > Architecture
|
|
|
|
|
/// > Builder ("never unescaped interpolation").
|
|
|
|
|
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
|
|
|
|
|
if value.contains('\'') || 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 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_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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 = 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_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());
|
|
|
|
|
}
|
|
|
|
|
}
|