Skip to main content

rust_doctor/
git_scope.rs

1//! The scope a scan runs under: the whole workspace, the files changed since a
2//! base, or a baseline comparison against one.
3//!
4//! Three rules hold the module together.
5//!
6//! **Validated once, resolved from that.** `ScopeRequest::validate` returns a
7//! [`ValidatedScope`] whose base selector already passed the closed grammar,
8//! and resolution reads that and nothing else. Validation used to run twice
9//! over the same request, once as the gate in `lib.rs` and once inside
10//! `resolve_with`, which left a failure branch in resolution that no input
11//! could reach.
12//!
13//! **One resolved shape.** [`ResolvedScope`] is the three cases, and the public
14//! [`ScopeReport`] is the accessors over it. A second enum used to mirror it
15//! variant for variant so callers inside the crate could match on it, which
16//! made a fourth scope mode an edit in five places.
17//!
18//! **One constructor per shape.** [`ScopeReport::files_scope`] is the only way
19//! a file scope is built: it sorts, deduplicates and bounds. `includes` binary
20//! searches that order, and the invariant used to be established separately by
21//! the production path and by the test constructor, so a third site would have
22//! broken the search in silence rather than loudly.
23
24use std::fmt;
25use std::path::Path;
26
27use serde::{Serialize, Serializer};
28
29use crate::internal_error::InternalError;
30use crate::git::{GitCall, GitFailure, OUTPUT_TOO_LARGE, git_arguments, run_git};
31use crate::workspace_path;
32
33#[cfg(test)]
34mod tests;
35
36/// The stage every outcome of this pass is reported at.
37const STAGE: &str = "scope";
38
39const OID_OUTPUT_LIMIT: usize = 4_096;
40const DIFF_OUTPUT_LIMIT: usize = 1_048_576;
41const SCOPE_OUTPUT_LIMIT: usize = 2_097_152;
42const PATH_LIMIT: usize = 4_096;
43const FILE_LIMIT: usize = 10_000;
44
45const BASE_UNAVAILABLE: GitFailure =
46    GitFailure::new("base-unavailable", "Git base commit is unavailable.");
47const MERGE_BASE_UNAVAILABLE: GitFailure =
48    GitFailure::new("merge-base-unavailable", "Git merge base is unavailable.");
49const DIFF_FAILED: GitFailure =
50    GitFailure::new("git-diff-failed", "Git changed files could not be read.");
51
52/// The scope a caller asked for, before its base selector was checked.
53#[derive(Clone, PartialEq, Eq)]
54pub(crate) enum ScopeRequest {
55    Full,
56    Files { base: String },
57    Baseline { base: String },
58}
59
60impl ScopeRequest {
61    /// Checks the base selector against the closed grammar, once.
62    pub(crate) fn validate(&self) -> Result<ValidatedScope, InternalError> {
63        Ok(match self {
64            Self::Full => ValidatedScope::Full,
65            Self::Files { base } => ValidatedScope::Files(BaseSelector::new(base)?),
66            Self::Baseline { base } => ValidatedScope::Baseline(BaseSelector::new(base)?),
67        })
68    }
69}
70
71impl fmt::Debug for ScopeRequest {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Full => formatter.write_str("Full"),
75            Self::Files { .. } => formatter.write_str("Files { base: <redacted> }"),
76            Self::Baseline { .. } => formatter.write_str("Baseline { base: <redacted> }"),
77        }
78    }
79}
80
81/// A scope whose base selector passed [`BaseSelector::new`].
82///
83/// Resolution takes this rather than a `ScopeRequest`, which is what makes an
84/// invalid base unrepresentable at the point git is about to be handed one.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) enum ValidatedScope {
87    Full,
88    Files(BaseSelector),
89    Baseline(BaseSelector),
90}
91
92/// A base selector inside the grammar this tool accepts.
93///
94/// The grammar is closed rather than delegated to git: a selector reaches a
95/// command line, and `HEAD~1`, `main^{commit}` or a leading `-` are spellings
96/// this tool refuses to construct rather than spellings it asks git to judge.
97#[derive(Clone, PartialEq, Eq)]
98pub(crate) struct BaseSelector(String);
99
100impl BaseSelector {
101    fn new(base: &str) -> Result<Self, InternalError> {
102        if !is_valid_base(base) {
103            return Err(InternalError::new(
104                STAGE,
105                "invalid-base",
106                "Invalid Git base selector.",
107            ));
108        }
109        Ok(Self(base.to_owned()))
110    }
111
112    fn as_str(&self) -> &str {
113        &self.0
114    }
115}
116
117/// A branch name is the caller's, and no error or trace of this crate carries
118/// it: the selector redacts itself rather than relying on every formatter that
119/// might reach one.
120impl fmt::Debug for BaseSelector {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str("<redacted>")
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "lowercase")]
128pub enum ScopeMode {
129    Full,
130    Files,
131    Baseline,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
135#[serde(rename_all = "lowercase")]
136pub enum ExecutionScope {
137    Workspace,
138}
139
140/// The resolved scope, published through accessors.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ScopeReport {
143    kind: ResolvedScope,
144}
145
146/// The three shapes a resolved scope takes.
147///
148/// Crate callers match on this directly through [`ScopeReport::kind`]. It stays
149/// out of the public API because the published surface is the accessors and the
150/// versioned JSON, not the variant list.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub(crate) enum ResolvedScope {
153    Full,
154    Files {
155        comparison_base: String,
156        files: Vec<String>,
157    },
158    Baseline {
159        comparison_base: String,
160    },
161}
162
163#[derive(Serialize)]
164struct SerializedScope<'a> {
165    mode: ScopeMode,
166    execution_scope: ExecutionScope,
167    comparison_base: Option<&'a str>,
168    files: Option<&'a [String]>,
169}
170
171impl ScopeReport {
172    pub const fn mode(&self) -> ScopeMode {
173        match self.kind {
174            ResolvedScope::Full => ScopeMode::Full,
175            ResolvedScope::Files { .. } => ScopeMode::Files,
176            ResolvedScope::Baseline { .. } => ScopeMode::Baseline,
177        }
178    }
179
180    pub const fn execution_scope(&self) -> ExecutionScope {
181        ExecutionScope::Workspace
182    }
183
184    pub fn comparison_base(&self) -> Option<&str> {
185        match &self.kind {
186            ResolvedScope::Full => None,
187            ResolvedScope::Files {
188                comparison_base, ..
189            }
190            | ResolvedScope::Baseline { comparison_base } => Some(comparison_base),
191        }
192    }
193
194    pub fn files(&self) -> Option<&[String]> {
195        match &self.kind {
196            ResolvedScope::Files { files, .. } => Some(files),
197            ResolvedScope::Full | ResolvedScope::Baseline { .. } => None,
198        }
199    }
200
201    pub(crate) const fn kind(&self) -> &ResolvedScope {
202        &self.kind
203    }
204
205    pub(crate) const fn full() -> Self {
206        Self {
207            kind: ResolvedScope::Full,
208        }
209    }
210
211    /// A baseline scope carries a validated hex object id and nothing else, so
212    /// its serialized form is a fixed hundred or so bytes and the report bound
213    /// is one it cannot reach.
214    pub(crate) fn baseline_scope(comparison_base: String) -> Self {
215        Self {
216            kind: ResolvedScope::Baseline { comparison_base },
217        }
218    }
219
220    /// The one place a file scope is built.
221    ///
222    /// Sorting and deduplicating here is what [`Self::includes`] binary
223    /// searches, and the bound is checked here because this is the only shape
224    /// that can reach it.
225    pub(crate) fn files_scope(
226        comparison_base: String,
227        mut files: Vec<String>,
228    ) -> Result<Self, InternalError> {
229        files.sort();
230        files.dedup();
231        let scope = Self {
232            kind: ResolvedScope::Files {
233                comparison_base,
234                files,
235            },
236        };
237        scope.ensure_output_bound()?;
238        Ok(scope)
239    }
240
241    /// Refuses a scope whose serialized form reaches the report limit.
242    ///
243    /// The measurement is the serialization itself, because normalization
244    /// expands paths (`%` becomes `%25`) and a bound on the diff bytes does not
245    /// bound what the report carries. A serializer that cannot answer is
246    /// treated as one that answered too large: an unmeasured scope is not a
247    /// bounded one. That branch is unreachable for a shape of enums and
248    /// strings, and refusing it is what keeps "published" and "measured" the
249    /// same set.
250    fn ensure_output_bound(&self) -> Result<(), InternalError> {
251        let measured = serde_json::to_vec(self).map_or(usize::MAX, |serialized| serialized.len());
252        (measured < SCOPE_OUTPUT_LIMIT)
253            .then_some(())
254            .ok_or_else(output_too_large)
255    }
256
257    pub(crate) fn includes(&self, path: Option<&str>) -> bool {
258        let ResolvedScope::Files { files, .. } = &self.kind else {
259            return true;
260        };
261        path.is_some_and(|path| {
262            files
263                .binary_search_by(|candidate| candidate.as_str().cmp(path))
264                .is_ok()
265        })
266    }
267}
268
269impl Serialize for ScopeReport {
270    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
271    where
272        S: Serializer,
273    {
274        SerializedScope {
275            mode: self.mode(),
276            execution_scope: self.execution_scope(),
277            comparison_base: self.comparison_base(),
278            files: self.files(),
279        }
280        .serialize(serializer)
281    }
282}
283
284pub(crate) fn resolve(
285    scope: &ValidatedScope,
286    workspace_root: &Path,
287) -> Result<ScopeReport, InternalError> {
288    resolve_with(scope, workspace_root, |call| {
289        run_git(Path::new("git"), workspace_root, call)
290    })
291}
292
293fn resolve_with(
294    scope: &ValidatedScope,
295    workspace_root: &Path,
296    mut run: impl FnMut(&GitCall) -> Result<Vec<u8>, InternalError>,
297) -> Result<ScopeReport, InternalError> {
298    // Each mode answers in full in its own arm, so a fourth one cannot be added
299    // without writing what it resolves to.
300    match scope {
301        ValidatedScope::Full => Ok(ScopeReport::full()),
302        ValidatedScope::Baseline(base) => Ok(ScopeReport::baseline_scope(resolve_comparison_base(
303            base,
304            workspace_root,
305            &mut run,
306        )?)),
307        ValidatedScope::Files(base) => {
308            let comparison_base = resolve_comparison_base(base, workspace_root, &mut run)?;
309            let diff = run(&scope_call(
310                workspace_root,
311                [
312                    "diff",
313                    "--no-ext-diff",
314                    "--no-renames",
315                    "--relative",
316                    "--name-only",
317                    "-z",
318                    "--diff-filter=ACMR",
319                    comparison_base.as_str(),
320                    "--",
321                    ".",
322                ],
323                DIFF_OUTPUT_LIMIT,
324                DIFF_FAILED,
325            ))?;
326            ScopeReport::files_scope(comparison_base, parse_paths(&diff)?)
327        }
328    }
329}
330
331/// Resolves the base selector to the single merge base the comparison runs
332/// against.
333///
334/// The selector is turned into an object id first, so every later argument this
335/// pass puts on a command line is a validated lowercase hex digest rather than
336/// anything the caller wrote.
337fn resolve_comparison_base(
338    base: &BaseSelector,
339    workspace_root: &Path,
340    mut run: impl FnMut(&GitCall) -> Result<Vec<u8>, InternalError>,
341) -> Result<String, InternalError> {
342    let revision = format!("{}^{{commit}}", base.as_str());
343    let base_answer = run(&scope_call(
344        workspace_root,
345        [
346            "rev-parse",
347            "--verify",
348            "--quiet",
349            "--end-of-options",
350            revision.as_str(),
351        ],
352        OID_OUTPUT_LIMIT,
353        BASE_UNAVAILABLE,
354    ))?;
355    let base_commit =
356        parse_single_oid(&base_answer).ok_or_else(|| BASE_UNAVAILABLE.error(STAGE))?;
357
358    let merge_answer = run(&scope_call(
359        workspace_root,
360        ["merge-base", "--all", base_commit.as_str(), "HEAD"],
361        OID_OUTPUT_LIMIT,
362        MERGE_BASE_UNAVAILABLE,
363    ))?;
364    let merge_bases =
365        parse_oids(&merge_answer).ok_or_else(|| MERGE_BASE_UNAVAILABLE.error(STAGE))?;
366    // A merge base of another hash length than the commit it was asked about is
367    // not an answer about that commit.
368    if merge_bases.iter().any(|oid| oid.len() != base_commit.len()) {
369        return Err(MERGE_BASE_UNAVAILABLE.error(STAGE));
370    }
371    match merge_bases.as_slice() {
372        [] => Err(MERGE_BASE_UNAVAILABLE.error(STAGE)),
373        [comparison_base] => Ok(comparison_base.clone()),
374        _ => Err(InternalError::new(
375            STAGE,
376            "merge-base-ambiguous",
377            "Git merge base is ambiguous.",
378        )),
379    }
380}
381
382fn scope_call<const N: usize>(
383    workspace_root: &Path,
384    operation: [&str; N],
385    stdout_limit: usize,
386    failure: GitFailure,
387) -> GitCall {
388    GitCall {
389        arguments: git_arguments(workspace_root, operation),
390        stdout_limit,
391        stage: STAGE,
392        failure,
393        overflow: OUTPUT_TOO_LARGE,
394    }
395}
396
397fn is_valid_base(base: &str) -> bool {
398    let bytes = base.as_bytes();
399    if matches!(bytes.len(), 40 | 64) && bytes.iter().all(u8::is_ascii_hexdigit) {
400        return true;
401    }
402    if bytes.is_empty()
403        || bytes.len() > 255
404        || !base.is_ascii()
405        || base.starts_with('-')
406        || base.contains("..")
407        || base.contains("//")
408    {
409        return false;
410    }
411    base.split('/').all(|component| {
412        !component.is_empty()
413            && !component.starts_with('.')
414            && !component.ends_with('.')
415            && !component.ends_with(".lock")
416            && component
417                .bytes()
418                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
419    })
420}
421
422fn parse_single_oid(output: &[u8]) -> Option<String> {
423    let mut oids = parse_oids(output)?;
424    (oids.len() == 1).then(|| oids.remove(0))
425}
426
427/// Reads whitespace-separated object ids, refusing the whole output if any
428/// token is not one.
429fn parse_oids(output: &[u8]) -> Option<Vec<String>> {
430    let output = std::str::from_utf8(output).ok()?;
431    output
432        .split_ascii_whitespace()
433        .map(|oid| {
434            (matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit()))
435                .then(|| oid.to_ascii_lowercase())
436        })
437        .collect()
438}
439
440/// Reads the NUL-terminated paths of a `--name-only -z` diff.
441///
442/// The byte count is not checked here: `run_git` refused anything past
443/// `DIFF_OUTPUT_LIMIT` before this ran, and the count of paths and the length
444/// of each are what bound the result the report carries.
445fn parse_paths(output: &[u8]) -> Result<Vec<String>, InternalError> {
446    // The last byte terminates the final path rather than separating an empty
447    // one, so it is split off rather than sliced away. Empty output is no
448    // change; anything else that does not end in NUL is a truncated answer.
449    let Some((&0, entries)) = output.split_last() else {
450        return if output.is_empty() {
451            Ok(Vec::new())
452        } else {
453            Err(invalid_path())
454        };
455    };
456
457    let mut files = Vec::new();
458    for entry in entries.split(|byte| *byte == 0) {
459        if files.len() == FILE_LIMIT {
460            return Err(InternalError::new(
461                STAGE,
462                "too-many-files",
463                "Git returned too many changed paths.",
464            ));
465        }
466        if entry.is_empty() || entry.len() > PATH_LIMIT {
467            return Err(invalid_path());
468        }
469        let path = std::str::from_utf8(entry).map_err(|_| invalid_path())?;
470        files.push(workspace_path::normalize_changed(path).ok_or_else(invalid_path)?);
471    }
472    Ok(files)
473}
474
475fn output_too_large() -> InternalError {
476    OUTPUT_TOO_LARGE.error(STAGE)
477}
478
479fn invalid_path() -> InternalError {
480    InternalError::new(
481        STAGE,
482        "git-path-invalid",
483        "Git returned an invalid changed path.",
484    )
485}