Close the loop: build, sanity-check, and publish #1
9 changed files with 287 additions and 49 deletions
8
SPEC.md
8
SPEC.md
|
|
@ -353,9 +353,11 @@ Open questions on the schema:
|
||||||
attestation verify` rather than reimplementing sigstore verification.)*
|
attestation verify` rather than reimplementing sigstore verification.)*
|
||||||
- **Builder**: for tiers 1–3 on pass, generates a PKGBUILD (strict
|
- **Builder**: for tiers 1–3 on pass, generates a PKGBUILD (strict
|
||||||
validation on every upstream-controlled string — version, asset name,
|
validation on every upstream-controlled string — version, asset name,
|
||||||
download URL — before it touches generated shell content; every
|
download URL — before it touches generated shell content; most fields
|
||||||
interpolated value is embedded in a single-quoted bash string and a
|
are single-quoted, but the `install()` line necessarily uses double
|
||||||
literal `'` or newline in the input is rejected outright, never
|
quotes so `${srcdir}`/`${pkgdir}` expand, so the validation rejects `'`,
|
||||||
|
newline, `$`, backtick, *and* backslash — safe for either quoting style
|
||||||
|
rather than assuming a value only ever lands in one of them — never
|
||||||
unescaped interpolation) and runs `makepkg`. *(Implemented —
|
unescaped interpolation) and runs `makepkg`. *(Implemented —
|
||||||
`src/builder.rs`. One fixed "prebuilt binary" PKGBUILD shape covers both
|
`src/builder.rs`. One fixed "prebuilt binary" PKGBUILD shape covers both
|
||||||
tracked packages so far: a bare-binary download (scaleway-cli) and a
|
tracked packages so far: a bare-binary download (scaleway-cli) and a
|
||||||
|
|
|
||||||
106
src/builder.rs
106
src/builder.rs
|
|
@ -3,7 +3,7 @@
|
||||||
//! PKGBUILD templating and upstream-string validation behind `build()`.
|
//! PKGBUILD templating and upstream-string validation behind `build()`.
|
||||||
|
|
||||||
use crate::config::Package;
|
use crate::config::Package;
|
||||||
use crate::hash::sha256_hex;
|
use crate::hash;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -84,9 +84,7 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
||||||
let binary_name = req.pkg.binary_name(req.pkg_name);
|
let binary_name = req.pkg.binary_name(req.pkg_name);
|
||||||
validate_pkgname(binary_name)?;
|
validate_pkgname(binary_name)?;
|
||||||
|
|
||||||
let artifact_data = std::fs::read(req.artifact_path)
|
let sha256 = hash::sha256_hex_file(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) {
|
let install_source = match archive_stem(req.asset_name) {
|
||||||
Some(stem) => format!("{stem}/{binary_name}"),
|
Some(stem) => format!("{stem}/{binary_name}"),
|
||||||
|
|
@ -118,6 +116,11 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result<PathBuf> {
|
fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result<PathBuf> {
|
||||||
let prefix = format!("{pkg_name}-{version}-");
|
let prefix = format!("{pkg_name}-{version}-");
|
||||||
for entry in std::fs::read_dir(build_dir)? {
|
for entry in std::fs::read_dir(build_dir)? {
|
||||||
|
|
@ -125,12 +128,12 @@ fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result
|
||||||
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if file_name.starts_with(&prefix) && file_name.ends_with(".pkg.tar.zst") {
|
if file_name.starts_with(&prefix) && file_name.contains(".pkg.tar.") {
|
||||||
return Ok(path);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bail!(
|
bail!(
|
||||||
"makepkg reported success but no {prefix}*.pkg.tar.zst found in {}",
|
"makepkg reported success but no {prefix}*.pkg.tar.* found in {}",
|
||||||
build_dir.display()
|
build_dir.display()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -145,12 +148,17 @@ fn archive_stem(asset_name: &str) -> Option<&str> {
|
||||||
.find_map(|ext| asset_name.strip_suffix(ext))
|
.find_map(|ext| asset_name.strip_suffix(ext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejects a single quote or newline: both would let upstream-controlled
|
/// Rejects characters that are dangerous in *either* quoting style the
|
||||||
/// text (asset names, download URLs) break out of the single-quoted bash
|
/// PKGBUILD template uses: a single quote breaks out of the single-quoted
|
||||||
/// strings the PKGBUILD template embeds them in. See SPEC.md > Architecture
|
/// fields (`pkgname`, `sha256sums`, ...); `$`, a backtick, or a backslash
|
||||||
/// > Builder ("never unescaped interpolation").
|
/// 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 SPEC.md > Architecture > Builder ("never unescaped
|
||||||
|
/// interpolation").
|
||||||
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
|
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
|
||||||
if value.contains('\'') || value.contains('\n') {
|
if value.contains(['\'', '\n', '$', '`', '\\']) {
|
||||||
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
|
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -201,6 +209,40 @@ mod tests {
|
||||||
assert_eq!(archive_stem("scaleway-cli_2.62.0_linux_amd64"), None);
|
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]
|
#[test]
|
||||||
fn validate_pkgver_accepts_dotted_version() {
|
fn validate_pkgver_accepts_dotted_version() {
|
||||||
assert!(validate_pkgver("2.62.0").is_ok());
|
assert!(validate_pkgver("2.62.0").is_ok());
|
||||||
|
|
@ -241,6 +283,24 @@ mod tests {
|
||||||
assert!(validate_shell_safe("download url", "https://example.com/a\nb").is_err());
|
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]
|
#[test]
|
||||||
fn validate_shell_safe_accepts_normal_url() {
|
fn validate_shell_safe_accepts_normal_url() {
|
||||||
assert!(validate_shell_safe("download url", "https://example.com/a/b.tar.gz").is_ok());
|
assert!(validate_shell_safe("download url", "https://example.com/a/b.tar.gz").is_ok());
|
||||||
|
|
@ -294,7 +354,7 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let artifact_path = dir.path().join("scaleway-cli_2.62.0_linux_amd64");
|
let artifact_path = dir.path().join("scaleway-cli_2.62.0_linux_amd64");
|
||||||
std::fs::write(&artifact_path, b"binary-bytes").unwrap();
|
std::fs::write(&artifact_path, b"binary-bytes").unwrap();
|
||||||
let expected_sha = sha256_hex(b"binary-bytes");
|
let expected_sha = hash::sha256_hex(b"binary-bytes");
|
||||||
|
|
||||||
let req = BuildRequest {
|
let req = BuildRequest {
|
||||||
pkg_name: "scaleway-cli",
|
pkg_name: "scaleway-cli",
|
||||||
|
|
@ -360,4 +420,26 @@ mod tests {
|
||||||
};
|
};
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
src/hash.rs
64
src/hash.rs
|
|
@ -1,19 +1,44 @@
|
||||||
//! One function, shared by two real callers (`verifier`, `builder`) —
|
//! Two functions, shared by two real callers (`verifier`, `builder`) —
|
||||||
//! not a general-purpose utils dump. See ARCHITECTURE.md > "organize by
|
//! not a general-purpose utils dump. See ARCHITECTURE.md > "organize by
|
||||||
//! pipeline stage, not by layer" for why that distinction matters.
|
//! pipeline stage, not by layer" for why that distinction matters.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Shared by `verifier` (same-origin-sha256 checks) and `builder` (every
|
/// In-memory digest — test-only now that both real callers (`verifier`,
|
||||||
/// generated PKGBUILD needs a `sha256sums` entry for makepkg's own local
|
/// `builder`) hash a file already on disk via `sha256_hex_file` instead.
|
||||||
/// integrity check, regardless of pkgwatch's own trust tier for that
|
/// Kept for building expected hashes from in-memory test fixtures.
|
||||||
/// package).
|
#[cfg(test)]
|
||||||
pub fn sha256_hex(data: &[u8]) -> String {
|
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(data);
|
hasher.update(data);
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same digest as `sha256_hex(&std::fs::read(path)?)`, but streamed in
|
||||||
|
/// fixed-size chunks instead of reading the whole file into memory first —
|
||||||
|
/// downloaded release assets are tens of MB, and the file is already on
|
||||||
|
/// disk, so there's no reason to hold a second full copy in memory just to
|
||||||
|
/// hash it.
|
||||||
|
pub fn sha256_hex_file(path: &Path) -> Result<String> {
|
||||||
|
let mut file =
|
||||||
|
std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.with_context(|| format!("reading {}", path.display()))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -26,4 +51,31 @@ mod tests {
|
||||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_hex_file_matches_in_memory_digest() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("data.bin");
|
||||||
|
// Bigger than one read chunk, to actually exercise the loop.
|
||||||
|
let data = vec![0x5au8; 200 * 1024];
|
||||||
|
std::fs::write(&path, &data).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_hex_file_matches_for_empty_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("empty.bin");
|
||||||
|
std::fs::write(&path, b"").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(b""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_hex_file_errors_on_missing_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let missing = dir.path().join("does-not-exist.bin");
|
||||||
|
assert!(sha256_hex_file(&missing).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ mod pipeline;
|
||||||
mod publisher;
|
mod publisher;
|
||||||
mod sanity;
|
mod sanity;
|
||||||
mod state;
|
mod state;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_support;
|
||||||
mod verifier;
|
mod verifier;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
|
||||||
103
src/pipeline.rs
103
src/pipeline.rs
|
|
@ -131,15 +131,19 @@ fn process_package(
|
||||||
fetched.verification.justification
|
fetched.verification.justification
|
||||||
);
|
);
|
||||||
|
|
||||||
let already_pending =
|
let previously_pending = state::load_pending_version(state_dir, name);
|
||||||
state::load_pending_version(state_dir, name).as_deref() == Some(latest.as_str());
|
let already_pending = previously_pending.as_deref() == Some(latest.as_str());
|
||||||
match decide_tier_action(
|
match decide_tier_action(
|
||||||
fetched.verification.tier,
|
fetched.verification.tier,
|
||||||
fetched.verification.passed,
|
fetched.verification.passed,
|
||||||
already_pending,
|
already_pending,
|
||||||
) {
|
) {
|
||||||
|
// Bail rather than just print-and-return: a verification failure
|
||||||
|
// is exactly the kind of event a monitoring setup (systemd
|
||||||
|
// OnFailure=, cron mail-on-error) needs a non-zero exit to catch —
|
||||||
|
// see run_check, which treats an Err here as a failed package.
|
||||||
TierAction::VerificationFailed => {
|
TierAction::VerificationFailed => {
|
||||||
println!(" verification failed — not publishing, not updating state");
|
bail!("verification failed — not publishing, not updating state");
|
||||||
}
|
}
|
||||||
TierAction::Publish => {
|
TierAction::Publish => {
|
||||||
println!(" tier 1-3 pass: building + publishing");
|
println!(" tier 1-3 pass: building + publishing");
|
||||||
|
|
@ -153,7 +157,14 @@ fn process_package(
|
||||||
}
|
}
|
||||||
TierAction::NewlyPending => {
|
TierAction::NewlyPending => {
|
||||||
state::save_pending_version(state_dir, name, &latest)?;
|
state::save_pending_version(state_dir, name, &latest)?;
|
||||||
println!(" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)");
|
match previously_pending {
|
||||||
|
Some(superseded) => println!(
|
||||||
|
" tier 4-6 pass: flagged for human review, superseding still-unreviewed {superseded} (`pkgwatch review` to approve {latest})"
|
||||||
|
),
|
||||||
|
None => println!(
|
||||||
|
" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -338,4 +349,88 @@ mod tests {
|
||||||
fn decide_tier_action_tier_4_to_6_still_pending_when_already_flagged() {
|
fn decide_tier_action_tier_4_to_6_still_pending_when_already_flagged() {
|
||||||
assert_eq!(decide_tier_action(4, true, true), TierAction::StillPending);
|
assert_eq!(decide_tier_action(4, true, true), TierAction::StillPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for the exit-code gap this PR fixes: a verification
|
||||||
|
/// failure previously returned `Ok(())` from `process_package`, so
|
||||||
|
/// `run_check` never counted it as a failure and the process exited 0
|
||||||
|
/// even though the single most security-relevant check had failed.
|
||||||
|
/// Exercises the full check -> fetch -> verify path against a mocked
|
||||||
|
/// GitHub (no real network), stopping before any build/publish step
|
||||||
|
/// since verification failure returns before reaching those.
|
||||||
|
#[test]
|
||||||
|
fn process_package_returns_err_on_verification_failure() {
|
||||||
|
let mut server = mockito::Server::new();
|
||||||
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let feed = format!(
|
||||||
|
r#"<feed><link rel="alternate" href="{}/o/r/releases/tag/v1.0.0"/></feed>"#,
|
||||||
|
server.url()
|
||||||
|
);
|
||||||
|
let _atom = server
|
||||||
|
.mock("GET", "/o/r/releases.atom")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(feed)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
||||||
|
let sums_url = format!("{}/download/SHA256SUMS", server.url());
|
||||||
|
let release_body = format!(
|
||||||
|
r#"{{"assets": [
|
||||||
|
{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}},
|
||||||
|
{{"name": "SHA256SUMS", "browser_download_url": "{sums_url}"}}
|
||||||
|
]}}"#
|
||||||
|
);
|
||||||
|
let _release = server
|
||||||
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(release_body)
|
||||||
|
.create();
|
||||||
|
let _asset = server
|
||||||
|
.mock("GET", "/download/thing.tar.gz")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(b"artifact-bytes".as_slice())
|
||||||
|
.create();
|
||||||
|
// Wrong hash for "artifact-bytes" — forces a verification failure.
|
||||||
|
let _sums = server
|
||||||
|
.mock("GET", "/download/SHA256SUMS")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n",
|
||||||
|
)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let pkg: Package = toml::from_str(
|
||||||
|
r#"
|
||||||
|
repo = "o/r"
|
||||||
|
asset_pattern = "thing.tar.gz"
|
||||||
|
[verification]
|
||||||
|
method = "same-origin-sha256"
|
||||||
|
checksum_asset_pattern = "SHA256SUMS"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let state_dir = tempfile::tempdir().unwrap();
|
||||||
|
let work_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let err = process_package(
|
||||||
|
&client,
|
||||||
|
&endpoints,
|
||||||
|
state_dir.path(),
|
||||||
|
work_dir.path(),
|
||||||
|
"thing",
|
||||||
|
&pkg,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("verification failed"));
|
||||||
|
// Neither published nor queued for review — a failed verification
|
||||||
|
// shouldn't leave any trace in state.
|
||||||
|
assert_eq!(state::load_last_version(state_dir.path(), "thing"), None);
|
||||||
|
assert_eq!(state::load_pending_version(state_dir.path(), "thing"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,21 +96,13 @@ fn ensure_registered_at(pacman_conf: &Path, repo_name: &str, repo_dir: &Path) ->
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::test_support::write_executable_script;
|
||||||
fn write_stub(dir: &Path, name: &str, script: &str) -> PathBuf {
|
|
||||||
let path = dir.join(name);
|
|
||||||
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
|
|
||||||
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
|
||||||
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
|
||||||
std::fs::set_permissions(&path, perms).unwrap();
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_copies_package_and_invokes_repo_add() {
|
fn publish_copies_package_and_invokes_repo_add() {
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
let stub_dir = tempfile::tempdir().unwrap();
|
||||||
let log_path = stub_dir.path().join("invoked_with.txt");
|
let log_path = stub_dir.path().join("invoked_with.txt");
|
||||||
let repo_add = write_stub(
|
let repo_add = write_executable_script(
|
||||||
stub_dir.path(),
|
stub_dir.path(),
|
||||||
"fake-repo-add",
|
"fake-repo-add",
|
||||||
&format!("echo \"$@\" > {}", log_path.display()),
|
&format!("echo \"$@\" > {}", log_path.display()),
|
||||||
|
|
@ -137,7 +129,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_errors_when_repo_add_fails() {
|
fn publish_errors_when_repo_add_fails() {
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
let stub_dir = tempfile::tempdir().unwrap();
|
||||||
let repo_add = write_stub(stub_dir.path(), "fake-repo-add-fail", "exit 1");
|
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-fail", "exit 1");
|
||||||
|
|
||||||
let src_dir = tempfile::tempdir().unwrap();
|
let src_dir = tempfile::tempdir().unwrap();
|
||||||
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
||||||
|
|
@ -151,7 +143,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_creates_repo_dir_if_missing() {
|
fn publish_creates_repo_dir_if_missing() {
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
let stub_dir = tempfile::tempdir().unwrap();
|
||||||
let repo_add = write_stub(stub_dir.path(), "fake-repo-add-ok", "exit 0");
|
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-ok", "exit 0");
|
||||||
|
|
||||||
let src_dir = tempfile::tempdir().unwrap();
|
let src_dir = tempfile::tempdir().unwrap();
|
||||||
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,7 @@ pub fn run(check: &SanityCheck, pkg_bin_dir: &Path, expected_version: &str) -> R
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::test_support::write_executable_script as write_fake_binary;
|
||||||
fn write_fake_binary(dir: &Path, name: &str, script: &str) {
|
|
||||||
let path = dir.join(name);
|
|
||||||
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
|
|
||||||
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
|
||||||
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
|
||||||
std::fs::set_permissions(&path, perms).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_passes_when_reported_version_matches() {
|
fn run_passes_when_reported_version_matches() {
|
||||||
|
|
|
||||||
21
src/test_support.rs
Normal file
21
src/test_support.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
|
||||||
|
//! (`publisher`, `sanity`) — not production code, and not built outside
|
||||||
|
//! `cargo test`. See ARCHITECTURE.md > "organize by pipeline stage, not
|
||||||
|
//! by layer": this exists to remove one specific piece of duplication
|
||||||
|
//! (two near-identical copies of "write an executable shell script"), not
|
||||||
|
//! as a general test-utils dump.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Writes an executable `#!/bin/sh` script named `name` into `dir`,
|
||||||
|
/// running `body` as its contents. Used to stand in for a real binary
|
||||||
|
/// (`repo-add`, a package's own `--version` command) in tests, without
|
||||||
|
/// needing the real tool installed or a mutated global `PATH`.
|
||||||
|
pub(crate) fn write_executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||||
|
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
||||||
|
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
||||||
|
std::fs::set_permissions(&path, perms).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ use crate::checker::version_from_tag;
|
||||||
use crate::config::Verification;
|
use crate::config::Verification;
|
||||||
use crate::fetcher;
|
use crate::fetcher;
|
||||||
use crate::github::GithubEndpoints;
|
use crate::github::GithubEndpoints;
|
||||||
use crate::hash::sha256_hex;
|
use crate::hash;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -51,8 +51,7 @@ pub fn verify(
|
||||||
.context("artifact path has no filename")?;
|
.context("artifact path has no filename")?;
|
||||||
let expected = expected_checksum(&checksum_text, artifact_name)?;
|
let expected = expected_checksum(&checksum_text, artifact_name)?;
|
||||||
|
|
||||||
let data = std::fs::read(artifact_path)?;
|
let actual = hash::sha256_hex_file(artifact_path)?;
|
||||||
let actual = sha256_hex(&data);
|
|
||||||
let passed = actual == expected;
|
let passed = actual == expected;
|
||||||
|
|
||||||
Ok(VerificationResult {
|
Ok(VerificationResult {
|
||||||
|
|
@ -192,7 +191,7 @@ mod tests {
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
||||||
std::fs::write(&artifact_path, b"hello world").unwrap();
|
std::fs::write(&artifact_path, b"hello world").unwrap();
|
||||||
let expected_hash = sha256_hex(b"hello world");
|
let expected_hash = hash::sha256_hex(b"hello world");
|
||||||
|
|
||||||
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
||||||
let release_body = format!(
|
let release_body = format!(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue