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