1use crate::config::{canonical_platform_path, normalize_platform_path};
4use crate::model::{stable_fingerprint_parts, Finding, Suppression};
5use crate::resource_limits::{read_utf8, MAX_BASELINE_INPUT_BYTES};
6use anyhow::{bail, Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14const MAX_BASELINE_CHECKOUT_BYTES: u64 = 512 * 1024 * 1024;
16
17pub fn git_changed_files(root: &Path, base: &str) -> Result<BTreeSet<PathBuf>> {
19 let base_oid = resolve_commit(root, base)?;
20 let tracked = git_paths(
21 root,
22 &[
23 "diff",
24 "--relative",
25 "--name-only",
26 "-z",
27 "--diff-filter=ACMR",
28 &base_oid,
29 "--",
30 ],
31 &format!("git diff against `{base}` failed"),
32 )?;
33 let untracked = git_paths(
34 root,
35 &["ls-files", "--others", "--exclude-standard", "-z", "--"],
36 "git untracked-file discovery failed",
37 )?;
38 Ok(tracked
39 .into_iter()
40 .chain(untracked)
41 .map(|path| root.join(path))
42 .collect())
43}
44
45const INHERITED_GIT_VARIABLES: [&str; 8] = [
49 "GIT_DIR",
50 "GIT_WORK_TREE",
51 "GIT_INDEX_FILE",
52 "GIT_OBJECT_DIRECTORY",
53 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
54 "GIT_COMMON_DIR",
55 "GIT_PREFIX",
56 "GIT_CEILING_DIRECTORIES",
57];
58
59fn git_at(root: &Path) -> Command {
62 let mut command = Command::new("git");
63 for variable in INHERITED_GIT_VARIABLES {
64 command.env_remove(variable);
65 }
66 command.arg("-C").arg(root);
67 command
68}
69
70fn resolve_commit(root: &Path, base: &str) -> Result<String> {
75 if base.trim().is_empty() || base.starts_with('-') {
76 bail!("invalid --changed-since revision `{base}`");
77 }
78 let revision = format!("{base}^{{commit}}");
79 let output = git_at(root)
80 .args(["rev-parse", "--verify", "--end-of-options"])
81 .arg(&revision)
82 .output()
83 .with_context(|| "failed to resolve the --changed-since revision")?;
84 if !output.status.success() {
85 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
86 bail!("could not resolve --changed-since revision `{base}`: {stderr}");
87 }
88 let oid = String::from_utf8(output.stdout)
89 .context("git returned a non-UTF-8 commit object id")?
90 .trim()
91 .to_owned();
92 if oid.is_empty() || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) {
93 bail!("git returned an invalid commit object id for `{base}`");
94 }
95 Ok(oid)
96}
97
98pub(crate) fn baseline_snapshot(
101 root: &Path,
102 revision: &str,
103) -> Result<(tempfile::TempDir, PathBuf)> {
104 if revision.trim().is_empty() || revision.starts_with('-') {
105 bail!("invalid baseline revision `{revision}`");
106 }
107 let temporary = tempfile::tempdir().context("cannot create temporary baseline checkout")?;
108 let git = |directory: &Path, args: &[&std::ffi::OsStr]| -> Result<Vec<u8>> {
109 let mut command = git_at(directory);
110 for (name, _) in std::env::vars_os() {
113 if name
115 .to_string_lossy()
116 .to_ascii_uppercase()
117 .starts_with("GIT_")
118 {
119 command.env_remove(name);
120 }
121 }
122 command
123 .env("GIT_CONFIG_NOSYSTEM", "1")
124 .env("GIT_NO_REPLACE_OBJECTS", "1")
125 .env("GIT_NO_LAZY_FETCH", "1")
126 .env("GIT_TERMINAL_PROMPT", "0")
127 .env("GIT_CONFIG_GLOBAL", temporary.path().join("no-config"))
128 .arg("-c")
129 .arg(format!(
130 "core.hooksPath={}",
131 temporary.path().join("no-hooks").display()
132 ))
133 .args(["-c", "core.fsmonitor=false"])
134 .args(args);
135 if args.first().is_some_and(|arg| *arg == "ls-tree") {
136 let limit = 28 * crate::resource_limits::MAX_WORKSPACE_SCAN_ENTRIES as u64;
138 let mut child = command
139 .stdout(std::process::Stdio::piped())
140 .stderr(std::process::Stdio::null())
141 .spawn()?;
142 let mut bytes = Vec::new();
143 let read = child
144 .stdout
145 .take()
146 .context("missing baseline Git stdout")
147 .and_then(|stdout| Ok(stdout.take(limit + 1).read_to_end(&mut bytes)?));
148 if read.is_err() || bytes.len() as u64 > limit {
149 let _ = child.kill();
150 let _ = child.wait();
151 read?;
152 bail!("baseline tree listing exceeds the bounded snapshot metadata limit");
153 }
154 if !child.wait()?.success() {
155 bail!("baseline Git tree enumeration failed");
156 }
157 return Ok(bytes);
158 }
159 let output = command.output().context("failed to run Git for baseline")?;
160 if !output.status.success() {
161 bail!(
162 "baseline Git operation failed: {}",
163 String::from_utf8_lossy(&output.stderr).trim()
164 );
165 }
166 Ok(output.stdout)
167 };
168 let oid = git(
169 root,
170 &[
171 "rev-parse".as_ref(),
172 "--verify".as_ref(),
173 "--end-of-options".as_ref(),
174 format!("{revision}^{{commit}}").as_ref(),
175 ],
176 )?;
177 let oid = String::from_utf8(oid).context("invalid baseline commit id")?;
178 let tree = git(
179 root,
180 &[
181 "ls-tree".as_ref(),
182 "--full-tree".as_ref(),
183 "-r".as_ref(),
184 "--format=%(objectmode) %(objectsize)".as_ref(),
185 oid.trim().as_ref(),
186 ],
187 )?;
188 let mut total_bytes = 0_u64;
189 for (index, entry) in String::from_utf8(tree)?.lines().enumerate() {
190 if entry.starts_with("160000 ") {
191 bail!("baseline checkout contains unsupported submodules");
192 }
193 let (_, size) = entry
194 .split_once(' ')
195 .context("invalid baseline tree entry")?;
196 total_bytes = total_bytes.saturating_add(size.parse::<u64>()?);
197 if total_bytes > MAX_BASELINE_CHECKOUT_BYTES
198 || index >= crate::resource_limits::MAX_WORKSPACE_SCAN_ENTRIES
199 {
200 bail!("baseline checkout exceeds the 512 MiB / 100000-file snapshot limit");
201 }
202 }
203 let repository = git(root, &["rev-parse".as_ref(), "--show-toplevel".as_ref()])?;
204 let repository = crate::config::normalize_platform_path(path_from_git_bytes(
206 repository.strip_suffix(b"\n").unwrap_or(&repository),
207 )?);
208 let relative = root
209 .strip_prefix(&repository)
210 .context("baseline root is outside repository")?;
211 let checkout = temporary.path().join("checkout");
212 git(
213 &repository,
214 &[
215 "clone".as_ref(),
216 "--shared".as_ref(),
217 "--no-checkout".as_ref(),
218 "--template=".as_ref(),
219 "--".as_ref(),
220 repository.as_os_str(),
221 checkout.as_os_str(),
222 ],
223 )?;
224 git(
225 &checkout,
226 &[
227 "checkout".as_ref(),
228 "--detach".as_ref(),
229 oid.trim().as_ref(),
230 ],
231 )?;
232 let scoped = checkout.join(relative);
233 if !scoped.is_dir() {
234 bail!("baseline revision does not contain the analysis directory");
235 }
236 let scoped = crate::config::canonical_platform_path(&scoped)?;
237 if !scoped.starts_with(crate::config::canonical_platform_path(&checkout)?) {
238 bail!("baseline analysis directory escapes its checkout");
239 }
240 Ok((temporary, scoped))
241}
242
243pub fn ensure_changed_root_is_trackable(root: &Path) -> Result<()> {
245 let root = normalize_platform_path(root.to_path_buf());
246 let repository = git_at(&root)
247 .args(["rev-parse", "--show-toplevel"])
248 .output()
249 .with_context(|| "failed to locate the Git repository for changed-files mode")?;
250 if !repository.status.success() {
251 bail!(
252 "changed-files mode requires a Git repository: {}",
253 String::from_utf8_lossy(&repository.stderr).trim()
254 );
255 }
256 let repository = canonical_platform_path(Path::new(
257 String::from_utf8(repository.stdout)
258 .context("git returned a non-UTF-8 repository path")?
259 .trim(),
260 ))
261 .with_context(|| "failed to canonicalize the Git repository root")?;
262 if root == repository {
263 return Ok(());
264 }
265 let relative = root.strip_prefix(&repository).with_context(|| {
266 format!(
267 "target {} is outside Git repository {}",
268 root.display(),
269 repository.display()
270 )
271 })?;
272 let ignored = git_at(&repository)
273 .args(["check-ignore", "--quiet", "--"])
274 .arg(relative)
275 .status()
276 .with_context(|| "failed to check whether the changed-files target is ignored")?;
277 match ignored.code() {
278 Some(0) => bail!(
279 "changed-files target {} is ignored by Git and cannot be analyzed incrementally",
280 root.display()
281 ),
282 Some(1) => Ok(()),
283 _ => bail!("git check-ignore failed for target {}", root.display()),
284 }
285}
286
287fn git_paths(root: &Path, arguments: &[&str], failure: &str) -> Result<Vec<PathBuf>> {
291 let output = git_at(root)
292 .args(["-c", "core.quotePath=false"])
293 .args(arguments)
294 .output()
295 .with_context(|| "failed to run git for changed-files mode")?;
296 if !output.status.success() {
297 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
298 bail!("{failure}: {stderr}");
299 }
300 output
301 .stdout
302 .split(|byte| *byte == b'\0')
303 .filter(|path| !path.is_empty())
304 .map(path_from_git_bytes)
305 .collect()
306}
307
308#[cfg(unix)]
310fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
311 use std::ffi::OsStr;
312 use std::os::unix::ffi::OsStrExt;
313
314 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
315}
316
317#[cfg(not(unix))]
319fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
320 Ok(PathBuf::from(
321 std::str::from_utf8(bytes).context("git returned a non-UTF-8 file name")?,
322 ))
323}
324
325pub fn retain_changed_findings(findings: &mut Vec<Finding>, changed: &BTreeSet<PathBuf>) {
327 findings.retain(|finding| {
328 changed.contains(&finding.primary.path)
329 || finding
330 .related
331 .iter()
332 .any(|location| changed.contains(&location.path))
333 });
334}
335
336pub const BASELINE_SCHEMA_VERSION: u32 = 3;
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341#[non_exhaustive]
343pub struct BaselineFile {
344 pub schema_version: u32,
346 pub fingerprints: BTreeMap<String, usize>,
348}
349
350impl BaselineFile {
351 pub fn new(fingerprints: BTreeMap<String, usize>) -> Self {
353 Self {
354 schema_version: BASELINE_SCHEMA_VERSION,
355 fingerprints,
356 }
357 }
358
359 fn empty() -> Self {
361 Self::new(BTreeMap::new())
362 }
363}
364
365impl Default for BaselineFile {
366 fn default() -> Self {
368 Self::new(BTreeMap::new())
369 }
370}
371
372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
373#[non_exhaustive]
375pub struct BaselineOutcome {
376 pub suppressed: usize,
378 pub new_findings: usize,
380 pub added: usize,
382 pub removed: usize,
384}
385
386pub fn apply_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
391 let baseline = load_baseline(baseline_path)?;
392 apply_loaded_baseline(findings, baseline_path, &baseline)
393}
394
395pub(crate) fn apply_reference_baseline(
400 findings: &mut [Finding],
401 accepted: &[Finding],
402 revision: &str,
403) -> Result<BaselineOutcome> {
404 apply_loaded_baseline(findings, Path::new(revision), &build_baseline(accepted))
405}
406
407pub fn update_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
409 let previous = if baseline_path.is_file() {
410 load_baseline_for_update(baseline_path)?
411 } else {
412 BaselineFile::empty()
413 };
414 let current = build_baseline(findings);
415 let added = count_added(&previous.fingerprints, ¤t.fingerprints);
416 let removed = count_added(¤t.fingerprints, &previous.fingerprints);
417 save_baseline(baseline_path, ¤t)?;
418 let mut outcome = apply_loaded_baseline(findings, baseline_path, ¤t)?;
419 outcome.added = added;
420 outcome.removed = removed;
421 Ok(outcome)
422}
423
424fn load_baseline(baseline_path: &Path) -> Result<BaselineFile> {
426 let baseline = parse_baseline(baseline_path)?;
427 if baseline.schema_version != BASELINE_SCHEMA_VERSION {
428 bail!(
429 "unsupported baseline schema version `{}` (expected `{}`); regenerate it with --update-baseline",
430 baseline.schema_version,
431 BASELINE_SCHEMA_VERSION
432 );
433 }
434 Ok(baseline)
435}
436
437fn load_baseline_for_update(baseline_path: &Path) -> Result<BaselineFile> {
440 let baseline = parse_baseline(baseline_path)?;
441 if baseline.schema_version > BASELINE_SCHEMA_VERSION {
442 bail!(
443 "baseline schema version `{}` is newer than supported version `{}` and cannot be replaced safely",
444 baseline.schema_version,
445 BASELINE_SCHEMA_VERSION
446 );
447 }
448 Ok(baseline)
449}
450
451fn parse_baseline(baseline_path: &Path) -> Result<BaselineFile> {
455 let text = read_utf8(baseline_path, "baseline", MAX_BASELINE_INPUT_BYTES)?;
456 serde_json::from_str(&text)
458 .with_context(|| format!("failed to parse baseline {}", baseline_path.display()))
459}
460
461fn save_baseline(path: &Path, baseline: &BaselineFile) -> Result<()> {
464 if let Some(parent) = path.parent() {
465 fs::create_dir_all(parent)
466 .with_context(|| format!("failed to create baseline directory {}", parent.display()))?;
467 }
468 let text = serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
469 fs::write(path, format!("{text}\n"))
470 .with_context(|| format!("failed to write baseline {}", path.display()))
471}
472
473fn build_baseline(findings: &[Finding]) -> BaselineFile {
475 let mut baseline = BaselineFile::empty();
476 for finding in findings.iter().filter(|finding| finding.is_active()) {
477 *baseline
478 .fingerprints
479 .entry(finding_fingerprint(finding))
480 .or_default() += 1;
481 }
482 baseline
483}
484
485fn apply_loaded_baseline(
490 findings: &mut [Finding],
491 baseline_path: &Path,
492 baseline: &BaselineFile,
493) -> Result<BaselineOutcome> {
494 let mut allowance = baseline.fingerprints.clone();
495 let mut outcome = BaselineOutcome::default();
496 for finding in findings {
497 if !finding.is_active() {
498 continue;
499 }
500 let fingerprint = finding_fingerprint(finding);
501 match allowance.get_mut(&fingerprint) {
502 Some(remaining) if *remaining > 0 => {
503 *remaining -= 1;
504 finding.suppression = Some(Suppression {
505 reason: format!("present in baseline {}", baseline_path.display()),
506 });
507 outcome.suppressed += 1;
508 }
509 _ => outcome.new_findings += 1,
510 }
511 }
512 Ok(outcome)
513}
514
515pub fn finding_fingerprint(finding: &Finding) -> String {
517 let rule = finding.rule.to_string();
518 if let Some(cluster) = &finding.evidence.cluster {
519 let mut fingerprints = cluster
520 .definition_fingerprints
521 .iter()
522 .map(String::as_str)
523 .collect::<Vec<_>>();
524 fingerprints.sort_unstable();
525 return stable_fingerprint_parts(
526 ["finding-cluster", rule.as_str()]
527 .into_iter()
528 .chain(fingerprints),
529 );
530 }
531 if let Some(comparison) = &finding.evidence.comparison {
532 let mut sides = [
533 comparison.left_fingerprint.as_str(),
534 comparison.right_fingerprint.as_str(),
535 ];
536 sides.sort_unstable();
537 return stable_fingerprint_parts(
538 ["finding-comparison", rule.as_str()]
539 .into_iter()
540 .chain(sides),
541 );
542 }
543 stable_fingerprint_parts([
544 "finding-fallback",
545 rule.as_str(),
546 &finding.message,
547 &finding.evidence.matcher_difference,
548 &finding.evidence.handler_evidence,
549 ])
550}
551
552fn count_added(previous: &BTreeMap<String, usize>, current: &BTreeMap<String, usize>) -> usize {
555 current
556 .iter()
557 .map(|(fingerprint, count)| {
558 count.saturating_sub(previous.get(fingerprint).copied().unwrap_or_default())
559 })
560 .sum()
561}
562
563#[cfg(test)]
564mod tests;