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