Skip to main content

cuke_dedup/
modes.rs

1//! Changed-files and existing-findings baseline modes for incremental CI adoption.
2
3use 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
14// Snapshotting is additional disk work, separate from the analyzer's per-source read limits.
15const MAX_BASELINE_CHECKOUT_BYTES: u64 = 512 * 1024 * 1024;
16
17/// Returns tracked changes since `base` plus untracked files beneath `root`.
18pub 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
45/// Git environment variables that a `git` process exports to the hooks it runs. They outrank a
46/// child invocation's `-C`, so leaving them in place would point changed-files mode at whichever
47/// repository invoked the hook instead of the analyzed root.
48const 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
59/// Builds a `git` invocation bound to `root` and nothing else, so results describe the analyzed
60/// tree whether CukeDedup runs from a shell, CI, or another repository's Git hook.
61fn 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
94/// Materializes a fetched commit without running checkout hooks or configured filters.
95/// An independent local clone avoids registering worktrees or modifying the caller's index.
96pub(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        // Neither caller-supplied Git config nor global templates/filters may execute code.
107        // Clone does not copy the source repository's local config or hooks.
108        for (name, _) in std::env::vars_os() {
109            // Windows preserves spelling but treats environment names case-insensitively.
110            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            // Fixed format: six mode digits, space, at most 20 size digits, newline.
133            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    // Remove Git's one line terminator, not whitespace belonging to the path.
201    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
239/// Rejects changed-file mode when the analyzed root itself is excluded by Git.
240pub 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
316/// Retains findings whose primary or related location belongs to `changed`.
317pub 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
327/// Schema version of the compact, reviewable baseline format.
328pub const BASELINE_SCHEMA_VERSION: u32 = 3;
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332/// Accepted semantic finding fingerprints and their multiplicities.
333#[non_exhaustive]
334pub struct BaselineFile {
335    /// Baseline format version.
336    pub schema_version: u32,
337    /// Stable fingerprint counts, sorted for deterministic pull-request diffs.
338    pub fingerprints: BTreeMap<String, usize>,
339}
340
341impl BaselineFile {
342    /// Creates a baseline using the current schema version.
343    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/// Effect of applying or rewriting a baseline.
363#[non_exhaustive]
364pub struct BaselineOutcome {
365    /// Findings matched and suppressed by the baseline.
366    pub suppressed: usize,
367    /// Active findings beyond the baseline's recorded multiplicities.
368    pub new_findings: usize,
369    /// Fingerprint instances added while updating.
370    pub added: usize,
371    /// Fingerprint instances removed while updating.
372    pub removed: usize,
373}
374
375/// Suppresses findings found in a schema-compatible semantic baseline.
376///
377/// Multiplicity is consumed in finding order, so adding another instance of accepted
378/// duplication remains visible even when its content fingerprint is already present.
379pub 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
392/// Rewrites a baseline from the current active findings and suppresses those findings.
393pub 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, &current.fingerprints);
401    let removed = count_added(&current.fingerprints, &previous.fingerprints);
402    save_baseline(baseline_path, &current)?;
403    let mut outcome = apply_loaded_baseline(findings, baseline_path, &current)?;
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's default recursion limit remains enabled for untrusted baseline input.
436    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
487/// Returns a location-independent fingerprint for baseline comparison.
488pub 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;