1use 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
36const 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#[derive(Clone, PartialEq, Eq)]
54pub(crate) enum ScopeRequest {
55 Full,
56 Files { base: String },
57 Baseline { base: String },
58}
59
60impl ScopeRequest {
61 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#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) enum ValidatedScope {
87 Full,
88 Files(BaseSelector),
89 Baseline(BaseSelector),
90}
91
92#[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
117impl 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#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ScopeReport {
143 kind: ResolvedScope,
144}
145
146#[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 pub(crate) fn baseline_scope(comparison_base: String) -> Self {
215 Self {
216 kind: ResolvedScope::Baseline { comparison_base },
217 }
218 }
219
220 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 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 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
331fn 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 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
427fn 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
440fn parse_paths(output: &[u8]) -> Result<Vec<String>, InternalError> {
446 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}