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> {
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::canonical_platform_path(&scoped)?;
233 if !scoped.starts_with(crate::config::canonical_platform_path(&checkout)?) {
234 bail!("baseline analysis directory escapes its checkout");
235 }
236 Ok((temporary, scoped))
237}
238
239pub fn ensure_changed_root_is_trackable(root: &Path) -> Result<()> {
241 let root = normalize_platform_path(root.to_path_buf());
242 let repository = git_at(&root)
243 .args(["rev-parse", "--show-toplevel"])
244 .output()
245 .with_context(|| "failed to locate the Git repository for changed-files mode")?;
246 if !repository.status.success() {
247 bail!(
248 "changed-files mode requires a Git repository: {}",
249 String::from_utf8_lossy(&repository.stderr).trim()
250 );
251 }
252 let repository = canonical_platform_path(Path::new(
253 String::from_utf8(repository.stdout)
254 .context("git returned a non-UTF-8 repository path")?
255 .trim(),
256 ))
257 .with_context(|| "failed to canonicalize the Git repository root")?;
258 if root == repository {
259 return Ok(());
260 }
261 let relative = root.strip_prefix(&repository).with_context(|| {
262 format!(
263 "target {} is outside Git repository {}",
264 root.display(),
265 repository.display()
266 )
267 })?;
268 let ignored = git_at(&repository)
269 .args(["check-ignore", "--quiet", "--"])
270 .arg(relative)
271 .status()
272 .with_context(|| "failed to check whether the changed-files target is ignored")?;
273 match ignored.code() {
274 Some(0) => bail!(
275 "changed-files target {} is ignored by Git and cannot be analyzed incrementally",
276 root.display()
277 ),
278 Some(1) => Ok(()),
279 _ => bail!("git check-ignore failed for target {}", root.display()),
280 }
281}
282
283fn git_paths(root: &Path, arguments: &[&str], failure: &str) -> Result<Vec<PathBuf>> {
284 let output = git_at(root)
285 .args(["-c", "core.quotePath=false"])
286 .args(arguments)
287 .output()
288 .with_context(|| "failed to run git for changed-files mode")?;
289 if !output.status.success() {
290 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
291 bail!("{failure}: {stderr}");
292 }
293 output
294 .stdout
295 .split(|byte| *byte == b'\0')
296 .filter(|path| !path.is_empty())
297 .map(path_from_git_bytes)
298 .collect()
299}
300
301#[cfg(unix)]
302fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
303 use std::ffi::OsStr;
304 use std::os::unix::ffi::OsStrExt;
305
306 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
307}
308
309#[cfg(not(unix))]
310fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
311 Ok(PathBuf::from(
312 std::str::from_utf8(bytes).context("git returned a non-UTF-8 file name")?,
313 ))
314}
315
316pub fn retain_changed_findings(findings: &mut Vec<Finding>, changed: &BTreeSet<PathBuf>) {
318 findings.retain(|finding| {
319 changed.contains(&finding.primary.path)
320 || finding
321 .related
322 .iter()
323 .any(|location| changed.contains(&location.path))
324 });
325}
326
327pub const BASELINE_SCHEMA_VERSION: u32 = 3;
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332#[non_exhaustive]
334pub struct BaselineFile {
335 pub schema_version: u32,
337 pub fingerprints: BTreeMap<String, usize>,
339}
340
341impl BaselineFile {
342 pub fn new(fingerprints: BTreeMap<String, usize>) -> Self {
344 Self {
345 schema_version: BASELINE_SCHEMA_VERSION,
346 fingerprints,
347 }
348 }
349
350 fn empty() -> Self {
351 Self::new(BTreeMap::new())
352 }
353}
354
355impl Default for BaselineFile {
356 fn default() -> Self {
357 Self::new(BTreeMap::new())
358 }
359}
360
361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
362#[non_exhaustive]
364pub struct BaselineOutcome {
365 pub suppressed: usize,
367 pub new_findings: usize,
369 pub added: usize,
371 pub removed: usize,
373}
374
375pub fn apply_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
380 let baseline = load_baseline(baseline_path)?;
381 apply_loaded_baseline(findings, baseline_path, &baseline)
382}
383
384pub(crate) fn apply_reference_baseline(
385 findings: &mut [Finding],
386 accepted: &[Finding],
387 revision: &str,
388) -> Result<BaselineOutcome> {
389 apply_loaded_baseline(findings, Path::new(revision), &build_baseline(accepted))
390}
391
392pub fn update_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
394 let previous = if baseline_path.is_file() {
395 load_baseline_for_update(baseline_path)?
396 } else {
397 BaselineFile::empty()
398 };
399 let current = build_baseline(findings);
400 let added = count_added(&previous.fingerprints, ¤t.fingerprints);
401 let removed = count_added(¤t.fingerprints, &previous.fingerprints);
402 save_baseline(baseline_path, ¤t)?;
403 let mut outcome = apply_loaded_baseline(findings, baseline_path, ¤t)?;
404 outcome.added = added;
405 outcome.removed = removed;
406 Ok(outcome)
407}
408
409fn load_baseline(baseline_path: &Path) -> Result<BaselineFile> {
410 let baseline = parse_baseline(baseline_path)?;
411 if baseline.schema_version != BASELINE_SCHEMA_VERSION {
412 bail!(
413 "unsupported baseline schema version `{}` (expected `{}`); regenerate it with --update-baseline",
414 baseline.schema_version,
415 BASELINE_SCHEMA_VERSION
416 );
417 }
418 Ok(baseline)
419}
420
421fn load_baseline_for_update(baseline_path: &Path) -> Result<BaselineFile> {
422 let baseline = parse_baseline(baseline_path)?;
423 if baseline.schema_version > BASELINE_SCHEMA_VERSION {
424 bail!(
425 "baseline schema version `{}` is newer than supported version `{}` and cannot be replaced safely",
426 baseline.schema_version,
427 BASELINE_SCHEMA_VERSION
428 );
429 }
430 Ok(baseline)
431}
432
433fn parse_baseline(baseline_path: &Path) -> Result<BaselineFile> {
434 let text = read_utf8(baseline_path, "baseline", MAX_BASELINE_INPUT_BYTES)?;
435 serde_json::from_str(&text)
437 .with_context(|| format!("failed to parse baseline {}", baseline_path.display()))
438}
439
440fn save_baseline(path: &Path, baseline: &BaselineFile) -> Result<()> {
441 if let Some(parent) = path.parent() {
442 fs::create_dir_all(parent)
443 .with_context(|| format!("failed to create baseline directory {}", parent.display()))?;
444 }
445 let text = serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
446 fs::write(path, format!("{text}\n"))
447 .with_context(|| format!("failed to write baseline {}", path.display()))
448}
449
450fn build_baseline(findings: &[Finding]) -> BaselineFile {
451 let mut baseline = BaselineFile::empty();
452 for finding in findings.iter().filter(|finding| finding.is_active()) {
453 *baseline
454 .fingerprints
455 .entry(finding_fingerprint(finding))
456 .or_default() += 1;
457 }
458 baseline
459}
460
461fn apply_loaded_baseline(
462 findings: &mut [Finding],
463 baseline_path: &Path,
464 baseline: &BaselineFile,
465) -> Result<BaselineOutcome> {
466 let mut allowance = baseline.fingerprints.clone();
467 let mut outcome = BaselineOutcome::default();
468 for finding in findings {
469 if !finding.is_active() {
470 continue;
471 }
472 let fingerprint = finding_fingerprint(finding);
473 match allowance.get_mut(&fingerprint) {
474 Some(remaining) if *remaining > 0 => {
475 *remaining -= 1;
476 finding.suppression = Some(Suppression {
477 reason: format!("present in baseline {}", baseline_path.display()),
478 });
479 outcome.suppressed += 1;
480 }
481 _ => outcome.new_findings += 1,
482 }
483 }
484 Ok(outcome)
485}
486
487pub fn finding_fingerprint(finding: &Finding) -> String {
489 let rule = finding.rule.to_string();
490 if let Some(cluster) = &finding.evidence.cluster {
491 let mut fingerprints = cluster
492 .definition_fingerprints
493 .iter()
494 .map(String::as_str)
495 .collect::<Vec<_>>();
496 fingerprints.sort_unstable();
497 return stable_fingerprint_parts(
498 ["finding-cluster", rule.as_str()]
499 .into_iter()
500 .chain(fingerprints),
501 );
502 }
503 if let Some(comparison) = &finding.evidence.comparison {
504 let mut sides = [
505 comparison.left_fingerprint.as_str(),
506 comparison.right_fingerprint.as_str(),
507 ];
508 sides.sort_unstable();
509 return stable_fingerprint_parts(
510 ["finding-comparison", rule.as_str()]
511 .into_iter()
512 .chain(sides),
513 );
514 }
515 stable_fingerprint_parts([
516 "finding-fallback",
517 rule.as_str(),
518 &finding.message,
519 &finding.evidence.matcher_difference,
520 &finding.evidence.handler_evidence,
521 ])
522}
523
524fn count_added(previous: &BTreeMap<String, usize>, current: &BTreeMap<String, usize>) -> usize {
525 current
526 .iter()
527 .map(|(fingerprint, count)| {
528 count.saturating_sub(previous.get(fingerprint).copied().unwrap_or_default())
529 })
530 .sum()
531}
532
533#[cfg(test)]
534mod tests;