Skip to main content

cuke_dedup/
config.rs

1//! Configuration loading and precedence.
2
3use crate::framework_config;
4use crate::model::{Rule, Severity};
5use crate::resource_limits::{
6    is_input_limit_error, read_utf8, MAX_CANDIDATE_COMPARISONS, MAX_CONFIG_INPUT_BYTES,
7    MAX_STRUCTURAL_CLASS_COMPARISONS, MAX_SUPPRESSION_REASON_CHARS,
8};
9use anyhow::{bail, Context, Result};
10use globset::Glob;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::{Component, Path, PathBuf};
14use std::str::FromStr;
15
16const DEFAULT_EXCLUDES: [&str; 5] = [
17    "**/node_modules/**",
18    "**/target/**",
19    "**/.features-gen/**",
20    "**/test-results/**",
21    "**/playwright-report/**",
22];
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25/// Destination format selected for analysis output.
26#[non_exhaustive]
27pub enum ReporterKind {
28    /// Human-readable output written to the supplied terminal stream.
29    Terminal,
30    /// Versioned machine-readable JSON output.
31    Json,
32    /// Compact JSON Lines output written to stdout for streaming consumers.
33    Jsonl,
34    /// Self-contained interactive HTML output.
35    Html,
36    /// SARIF 2.1.0 output for GitHub code scanning and other compatible tools.
37    Sarif,
38}
39
40impl FromStr for ReporterKind {
41    type Err = String;
42
43    fn from_str(value: &str) -> Result<Self, Self::Err> {
44        match value {
45            "terminal" => Ok(Self::Terminal),
46            "json" => Ok(Self::Json),
47            "jsonl" => Ok(Self::Jsonl),
48            "html" => Ok(Self::Html),
49            "sarif" => Ok(Self::Sarif),
50            _ => Err(format!("unknown reporter `{value}`")),
51        }
52    }
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57struct RawConfig {
58    #[serde(default)]
59    ruby_load_paths: Option<Vec<PathBuf>>,
60    #[serde(default)]
61    definitions: Option<Vec<String>>,
62    #[serde(default)]
63    features: Option<Vec<String>>,
64    #[serde(default)]
65    exclude: Option<Vec<String>>,
66    #[serde(default)]
67    exclude_defaults: Option<bool>,
68    #[serde(default)]
69    include_hidden: Option<bool>,
70    #[serde(default)]
71    reporters: Option<Vec<ReporterKind>>,
72    #[serde(default)]
73    output: Option<PathBuf>,
74    #[serde(default)]
75    threshold: Option<f64>,
76    #[serde(default)]
77    near_duplicate_handler_similarity: Option<f64>,
78    #[serde(default)]
79    require_features: Option<bool>,
80    #[serde(default)]
81    require_definitions: Option<bool>,
82    #[serde(default)]
83    fail_on_incomplete: Option<bool>,
84    #[serde(default)]
85    fail_on_unparseable: Option<bool>,
86    #[serde(default)]
87    registrations: Option<Vec<String>>,
88    #[serde(default)]
89    assertion_modules: Option<Vec<String>>,
90    #[serde(default)]
91    parameter_types: Option<BTreeMap<String, String>>,
92    #[serde(default)]
93    max_candidate_comparisons: Option<usize>,
94    #[serde(default)]
95    max_structural_class_comparisons: Option<usize>,
96    #[serde(default)]
97    no_metrics: Option<bool>,
98    #[serde(default)]
99    rules: Option<BTreeMap<String, Severity>>,
100    #[serde(default)]
101    suppressions: Option<Vec<SuppressionConfig>>,
102}
103
104#[derive(Debug, Clone, Deserialize, Default)]
105#[serde(rename_all = "camelCase")]
106struct PackageJson {
107    #[serde(default)]
108    cuke_dedup: Option<RawConfig>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", deny_unknown_fields)]
113/// A configuration entry that suppresses matching findings for a documented reason.
114pub struct SuppressionConfig {
115    /// Rule to suppress.
116    pub rule: Rule,
117    /// Human-readable justification recorded with suppressed findings.
118    pub reason: String,
119    #[serde(default)]
120    /// Optional glob selecting source paths.
121    pub path: Option<String>,
122    #[serde(default)]
123    /// Optional exact matcher selecting step definitions.
124    pub matcher: Option<String>,
125}
126
127#[derive(Debug, Clone, Default)]
128/// Command-line or embedding overrides applied after file-based configuration.
129pub struct ConfigOverrides {
130    /// Explicit JSON configuration file, bypassing automatic config-file discovery.
131    pub config_file: Option<PathBuf>,
132    /// Replacement definition-source globs.
133    pub definitions: Option<Vec<String>>,
134    /// Replacement feature-file globs.
135    pub features: Option<Vec<String>>,
136    /// Replacement custom exclusion globs.
137    pub exclude: Option<Vec<String>>,
138    /// Whether built-in generated-directory exclusions remain enabled.
139    pub exclude_defaults: Option<bool>,
140    /// Whether discovery descends into dot-directories.
141    pub include_hidden: Option<bool>,
142    /// Replacement reporter selection.
143    pub reporters: Option<Vec<ReporterKind>>,
144    /// Replacement report output directory.
145    pub output: Option<PathBuf>,
146    /// Maximum percentage of definitions allowed in active duplication errors.
147    pub threshold: Option<f64>,
148    /// Whether a run with no discovered feature files is an operational failure.
149    pub require_features: Option<bool>,
150    /// Whether machine reports omit runtime measurements.
151    pub no_metrics: Option<bool>,
152    /// Per-rule severity overrides.
153    pub rules: BTreeMap<Rule, Severity>,
154}
155
156#[derive(Debug, Clone, Default)]
157pub(crate) struct CliConfigOverrides {
158    pub(crate) require_definitions: Option<bool>,
159    /// Whether bounded or incomplete analysis is an operational failure.
160    pub(crate) fail_on_incomplete: Option<bool>,
161    /// Whether an unparseable source file is an operational failure rather than tolerated.
162    pub(crate) fail_on_unparseable: Option<bool>,
163    pub(crate) max_candidate_comparisons: Option<usize>,
164    pub(crate) max_structural_class_comparisons: Option<usize>,
165}
166
167#[derive(Debug, Clone, Serialize)]
168#[serde(rename_all = "camelCase")]
169/// Fully resolved and validated analyzer configuration.
170pub struct Config {
171    #[serde(flatten)]
172    frontend: crate::source_adapter::FrontendConfig,
173    /// Canonical repository root being analyzed.
174    pub root: PathBuf,
175    /// Globs selecting definition sources, or an empty list for automatic discovery.
176    pub definitions: Vec<String>,
177    /// Globs selecting classic Gherkin or Gherkin Markdown feature files.
178    pub features: Vec<String>,
179    /// Human-readable origin of the effective feature globs.
180    pub feature_pattern_origin: String,
181    /// Non-fatal diagnostics produced while inspecting framework configuration.
182    pub config_warnings: Vec<String>,
183    /// Standalone or package configuration source selected below CLI overrides.
184    pub config_source: String,
185    /// Globs excluded from discovery.
186    pub exclude: Vec<String>,
187    /// Whether built-in generated-directory exclusions are active.
188    pub exclude_defaults: bool,
189    /// Whether discovery descends into dot-directories.
190    pub include_hidden: bool,
191    /// Report formats emitted by the analyzer.
192    pub reporters: Vec<ReporterKind>,
193    /// Directory for file-based reports.
194    pub output: PathBuf,
195    /// Maximum percentage of definitions allowed in active duplication errors.
196    pub threshold: f64,
197    /// Minimum handler similarity (0.5–1.0) for a `near-duplicate-step` finding. Raising it demands
198    /// more handler evidence, suppressing systematically-named steps whose handlers differ.
199    pub near_duplicate_handler_similarity: f64,
200    /// Whether finding no feature files is an operational failure.
201    pub require_features: bool,
202    /// Whether extracting no step definitions is an operational failure.
203    pub require_definitions: bool,
204    /// Whether an incomplete corpus or truncated analysis is an operational failure.
205    pub fail_on_incomplete: bool,
206    /// Whether a source file that cannot be parsed is an operational failure rather than a
207    /// tolerated, reported incompleteness.
208    pub fail_on_unparseable: bool,
209    /// Additional local function names that register step definitions.
210    pub registrations: Vec<String>,
211    /// Additional module specifiers whose `expect` export is a trusted assertion factory.
212    pub assertion_modules: Vec<String>,
213    /// Project-defined Cucumber Expression parameter types and their regular expressions.
214    pub parameter_types: BTreeMap<String, String>,
215    /// Maximum unique definition pairs retained for analysis.
216    pub max_candidate_comparisons: usize,
217    /// Maximum structural candidate proposals considered from one handler class.
218    pub max_structural_class_comparisons: usize,
219    /// Whether machine reports omit runtime measurements for reproducibility.
220    pub no_metrics: bool,
221    /// Effective severity for each analysis rule.
222    pub rules: BTreeMap<Rule, Severity>,
223    /// Explicit, reason-bearing finding suppressions.
224    pub suppressions: Vec<SuppressionConfig>,
225    #[serde(skip)]
226    custom_exclude: Vec<String>,
227    #[serde(skip)]
228    output_from_project_config: bool,
229}
230
231impl Config {
232    /// Ordered, explicitly configured Ruby dependency source roots.
233    pub fn ruby_load_paths(&self) -> &[PathBuf] {
234        self.frontend.ruby_load_paths()
235    }
236
237    /// Loads framework, package, and standalone configuration beneath `root`, then applies
238    /// `overrides` in precedence order.
239    pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
240        Self::load_internal(root, overrides, CliConfigOverrides::default())
241    }
242
243    pub(crate) fn load_for_cli(
244        root: &Path,
245        overrides: ConfigOverrides,
246        cli_overrides: CliConfigOverrides,
247    ) -> Result<Self> {
248        Self::load_internal(root, overrides, cli_overrides)
249    }
250
251    /// Loads the configuration for `root` with the given overrides, resolving `root` to its
252    /// canonical, platform-normalized form first.
253    fn load_internal(
254        root: &Path,
255        overrides: ConfigOverrides,
256        cli_overrides: CliConfigOverrides,
257    ) -> Result<Self> {
258        let root = canonical_platform_path(root)
259            .with_context(|| format!("cannot access target directory {}", root.display()))?;
260        if !root.is_dir() {
261            bail!("target {} is not a directory", root.display());
262        }
263
264        let package_path = root.join("package.json");
265        let (package_config, package_json) = if package_path.is_file() {
266            let text = read_utf8(
267                &package_path,
268                "package configuration",
269                MAX_CONFIG_INPUT_BYTES,
270            )?;
271            // serde_json's default recursion limit remains enabled for untrusted config input.
272            let value = serde_json::from_str::<serde_json::Value>(&text)
273                .with_context(|| format!("failed to parse {}", package_path.display()))?;
274            let package = serde_json::from_value::<PackageJson>(value.clone())
275                .with_context(|| format!("failed to parse {}", package_path.display()))?;
276            (package.cuke_dedup, Some(value))
277        } else {
278            (None, None)
279        };
280
281        let explicit_config = overrides.config_file.as_ref().map(|path| {
282            if path.is_absolute() {
283                path.clone()
284            } else {
285                root.join(path)
286            }
287        });
288        let mut config_warnings = Vec::new();
289        let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
290            (Some(read_raw_config(&path)?), Some(path))
291        } else {
292            discover_config_file(&root, &mut config_warnings)?
293        };
294
295        let package_config = if standalone_config.is_none() {
296            package_config
297        } else {
298            None
299        };
300
301        let has_explicit_features = overrides.features.is_some()
302            || standalone_config
303                .as_ref()
304                .is_some_and(|config| config.features.is_some())
305            || package_config
306                .as_ref()
307                .is_some_and(|config| config.features.is_some());
308        let mut config = Self::defaults(root);
309        config.config_warnings = config_warnings;
310        if !has_explicit_features {
311            match framework_config::detect(&config.root, package_json.as_ref()) {
312                Ok(Some(framework)) => {
313                    config.features = framework.patterns;
314                    config.feature_pattern_origin = format!(
315                        "{} configuration {}",
316                        framework.framework,
317                        framework
318                            .source
319                            .strip_prefix(&config.root)
320                            .unwrap_or(&framework.source)
321                            .display()
322                    );
323                    config.config_warnings.extend(framework.warnings);
324                }
325                Ok(None) => {}
326                Err(error) if is_input_limit_error(&error) => return Err(error),
327                Err(error) => config.config_warnings.push(format!(
328                    "could not inspect framework configuration ({error:#}); using built-in feature patterns"
329                )),
330            }
331        }
332        if let Some(raw) = package_config {
333            let replaces_features = raw.features.is_some();
334            config.apply_raw(raw)?;
335            config.config_source = "package.json#cukeDedup".to_owned();
336            if replaces_features {
337                config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
338            }
339        }
340        if let Some(raw) = standalone_config {
341            let replaces_features = raw.features.is_some();
342            config.apply_raw(raw)?;
343            config.config_source = standalone_path
344                .as_ref()
345                .map(|path| config_source_name(path, &config.root))
346                .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
347            if replaces_features {
348                config.feature_pattern_origin = config.config_source.clone();
349            }
350        }
351        let cli_replaces_features = overrides.features.is_some();
352        config.apply_overrides(overrides);
353        if let Some(value) = cli_overrides.require_definitions {
354            config.require_definitions = value;
355        }
356        if let Some(value) = cli_overrides.fail_on_incomplete {
357            config.fail_on_incomplete = value;
358        }
359        if let Some(value) = cli_overrides.fail_on_unparseable {
360            config.fail_on_unparseable = value;
361        }
362        if let Some(value) = cli_overrides.max_candidate_comparisons {
363            config.max_candidate_comparisons = value;
364        }
365        if let Some(value) = cli_overrides.max_structural_class_comparisons {
366            config.max_structural_class_comparisons = value;
367        }
368        if cli_replaces_features {
369            config.feature_pattern_origin = "--features".to_owned();
370        }
371        config.validate()?;
372        Ok(config)
373    }
374
375    fn defaults(root: PathBuf) -> Self {
376        let rules = [
377            (Rule::DuplicateMatcher, Severity::Error),
378            (Rule::NormalizedMatcher, Severity::Error),
379            (Rule::AmbiguousStep, Severity::Error),
380            (Rule::OverlappingMatcher, Severity::Warning),
381            (Rule::DuplicateHandler, Severity::Error),
382            (Rule::NearDuplicateStep, Severity::Warning),
383            (Rule::ParameterizationCandidate, Severity::Warning),
384            (Rule::UnusedDefinition, Severity::Warning),
385        ]
386        .into_iter()
387        .collect();
388        Self {
389            frontend: Default::default(),
390            root,
391            definitions: Vec::new(),
392            features: vec!["**/*.{feature,feature.md}".to_owned()],
393            feature_pattern_origin: "built-in defaults".to_owned(),
394            config_warnings: Vec::new(),
395            config_source: "built-in defaults".to_owned(),
396            exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
397            exclude_defaults: true,
398            include_hidden: false,
399            reporters: vec![ReporterKind::Terminal],
400            output: PathBuf::from("reports/cuke-dedup"),
401            threshold: 0.0,
402            near_duplicate_handler_similarity: 0.70,
403            require_features: false,
404            require_definitions: false,
405            fail_on_incomplete: false,
406            fail_on_unparseable: false,
407            registrations: Vec::new(),
408            assertion_modules: Vec::new(),
409            parameter_types: BTreeMap::new(),
410            max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
411            max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
412            no_metrics: false,
413            rules,
414            suppressions: Vec::new(),
415            custom_exclude: Vec::new(),
416            output_from_project_config: false,
417        }
418    }
419
420    fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
421        if let Some(paths) = raw.ruby_load_paths {
422            self.frontend.set_ruby_load_paths(paths);
423        }
424        if let Some(value) = raw.definitions {
425            self.definitions = value;
426        }
427        if let Some(value) = raw.features {
428            self.features = value;
429        }
430        if let Some(value) = raw.exclude_defaults {
431            self.exclude_defaults = value;
432        }
433        if let Some(value) = raw.include_hidden {
434            self.include_hidden = value;
435        }
436        if let Some(value) = raw.exclude {
437            self.custom_exclude = value;
438        }
439        self.rebuild_excludes();
440        if let Some(value) = raw.reporters {
441            self.reporters = value;
442        }
443        if let Some(value) = raw.output {
444            self.output = value;
445            self.output_from_project_config = true;
446        }
447        if let Some(value) = raw.threshold {
448            self.threshold = value;
449        }
450        if let Some(value) = raw.near_duplicate_handler_similarity {
451            self.near_duplicate_handler_similarity = value;
452        }
453        if let Some(value) = raw.require_features {
454            self.require_features = value;
455        }
456        if let Some(value) = raw.require_definitions {
457            self.require_definitions = value;
458        }
459        if let Some(value) = raw.fail_on_incomplete {
460            self.fail_on_incomplete = value;
461        }
462        if let Some(value) = raw.fail_on_unparseable {
463            self.fail_on_unparseable = value;
464        }
465        if let Some(value) = raw.registrations {
466            self.registrations = value;
467        }
468        if let Some(value) = raw.assertion_modules {
469            self.assertion_modules = value;
470        }
471        if let Some(value) = raw.parameter_types {
472            self.parameter_types = value;
473        }
474        if let Some(value) = raw.max_candidate_comparisons {
475            self.max_candidate_comparisons = value;
476        }
477        if let Some(value) = raw.max_structural_class_comparisons {
478            self.max_structural_class_comparisons = value;
479        }
480        if let Some(value) = raw.no_metrics {
481            self.no_metrics = value;
482        }
483        if let Some(value) = raw.rules {
484            for (name, severity) in value {
485                let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
486                self.rules.insert(rule, severity);
487            }
488        }
489        if let Some(value) = raw.suppressions {
490            self.suppressions = value;
491        }
492        Ok(())
493    }
494
495    fn apply_overrides(&mut self, overrides: ConfigOverrides) {
496        if let Some(value) = overrides.definitions {
497            self.definitions = value;
498        }
499        if let Some(value) = overrides.features {
500            self.features = value;
501        }
502        if let Some(value) = overrides.exclude_defaults {
503            self.exclude_defaults = value;
504        }
505        if let Some(value) = overrides.include_hidden {
506            self.include_hidden = value;
507        }
508        if let Some(value) = overrides.exclude {
509            self.custom_exclude = value;
510        }
511        self.rebuild_excludes();
512        if let Some(value) = overrides.reporters {
513            self.reporters = value;
514        }
515        if let Some(value) = overrides.output {
516            self.output = value;
517            self.output_from_project_config = false;
518        }
519        if let Some(value) = overrides.threshold {
520            self.threshold = value;
521        }
522        if let Some(value) = overrides.require_features {
523            self.require_features = value;
524        }
525        if let Some(value) = overrides.no_metrics {
526            self.no_metrics = value;
527        }
528        self.rules.extend(overrides.rules);
529    }
530
531    fn rebuild_excludes(&mut self) {
532        self.exclude.clear();
533        if self.exclude_defaults {
534            self.exclude
535                .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
536        }
537        self.exclude.extend(self.custom_exclude.iter().cloned());
538    }
539
540    fn validate(&self) -> Result<()> {
541        if self.reporters.is_empty() {
542            bail!("at least one reporter must be configured");
543        }
544        if self.reporters.contains(&ReporterKind::Terminal)
545            && self.reporters.contains(&ReporterKind::Jsonl)
546        {
547            bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
548        }
549        if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
550            bail!("threshold must be a finite percentage from 0 through 100");
551        }
552        // A range check also rejects NaN (`contains` is false for it); config files cannot express a
553        // non-finite value, so no separate `is_finite` guard is reachable here.
554        if !(0.5..=1.0).contains(&self.near_duplicate_handler_similarity) {
555            bail!("nearDuplicateHandlerSimilarity must be a value from 0.5 through 1.0");
556        }
557        for (name, pattern) in &self.parameter_types {
558            if name.is_empty() || name.contains(['{', '}']) {
559                bail!("parameter type `{name}` must not be empty or contain braces");
560            }
561            crate::resource_limits::compile_regex(pattern).with_context(|| {
562                format!("invalid regular expression for parameter type `{name}`")
563            })?;
564        }
565        for module in &self.assertion_modules {
566            // A specifier is matched exactly against the import text, so surrounding whitespace or
567            // an empty entry would silently never match and quietly disable the setting.
568            if module.is_empty() || module.trim() != module {
569                bail!("assertion module `{module}` must be a module specifier without surrounding whitespace");
570            }
571        }
572        for name in &self.registrations {
573            if name.is_empty()
574                || !name
575                    .chars()
576                    .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
577                || name.starts_with(|character: char| character.is_ascii_digit())
578            {
579                bail!("registration `{name}` must be a JavaScript identifier");
580            }
581        }
582        self.validate_analysis_limits()?;
583        if self.output_from_project_config {
584            validate_project_output(&self.root, &self.output)?;
585        }
586        for suppression in &self.suppressions {
587            if suppression.reason.trim().is_empty() {
588                bail!(
589                    "suppression for {} must include a non-empty reason",
590                    suppression.rule
591                );
592            }
593            if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
594                bail!(
595                    "suppression reason for {} exceeds the {}-character limit",
596                    suppression.rule,
597                    MAX_SUPPRESSION_REASON_CHARS
598                );
599            }
600            if suppression.path.is_none() && suppression.matcher.is_none() {
601                bail!(
602                    "suppression for {} must select a path or matcher",
603                    suppression.rule
604                );
605            }
606            if let Some(pattern) = &suppression.path {
607                Glob::new(pattern).with_context(|| {
608                    format!(
609                        "invalid suppression path glob `{pattern}` for {}",
610                        suppression.rule
611                    )
612                })?;
613            }
614        }
615        Ok(())
616    }
617
618    pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
619        if self.max_candidate_comparisons == 0 {
620            bail!("maxCandidateComparisons must be greater than zero");
621        }
622        if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
623            bail!(
624                "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
625            );
626        }
627        if self.max_structural_class_comparisons == 0 {
628            bail!("maxStructuralClassComparisons must be greater than zero");
629        }
630        if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
631            bail!(
632                "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
633            );
634        }
635        Ok(())
636    }
637
638    /// Returns the configured severity for `rule`, defaulting to [`Severity::Off`].
639    pub fn severity(&self, rule: Rule) -> Severity {
640        self.rules.get(&rule).copied().unwrap_or(Severity::Off)
641    }
642
643    /// Resolves a report filename against the configured output directory.
644    pub fn output_path(&self, filename: &str) -> PathBuf {
645        if self.output.is_absolute() {
646            self.output.join(filename)
647        } else {
648            self.root.join(&self.output).join(filename)
649        }
650    }
651}
652
653fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
654    if output.is_absolute()
655        || output.components().any(|component| {
656            matches!(
657                component,
658                Component::ParentDir | Component::RootDir | Component::Prefix(_)
659            )
660        })
661    {
662        bail!(
663            "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
664            output.display()
665        );
666    }
667
668    let candidate = root.join(output);
669    let mut existing_ancestor = candidate.as_path();
670    while !existing_ancestor.exists() {
671        existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
672            anyhow::anyhow!(
673                "could not resolve project configuration output `{}`",
674                output.display()
675            )
676        })?;
677    }
678    let canonical_ancestor =
679        normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
680            format!(
681                "failed to validate project configuration output {}",
682                candidate.display()
683            )
684        })?);
685    if !canonical_ancestor.starts_with(root) {
686        bail!(
687            "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
688            output.display()
689        );
690    }
691    Ok(())
692}
693
694/// `path` without the Windows verbatim prefix, so it compares with `Config::root` and strips to
695/// a relative display path; unchanged on other platforms.
696pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
697    #[cfg(windows)]
698    {
699        if let Some(plain) = strip_verbatim_prefix(&path) {
700            return plain;
701        }
702    }
703    path
704}
705
706/// `path` without its `\\?\X:` or `\\?\UNC\server\share` verbatim prefix, rebuilt from its
707/// components without any `OsStr` to `str` conversion. `None` keeps the verbatim form.
708#[cfg(windows)]
709fn strip_verbatim_prefix(path: &Path) -> Option<PathBuf> {
710    use std::ffi::OsStr;
711    use std::path::Prefix;
712    let mut components = path.components();
713    let prefix = match components.next() {
714        Some(Component::Prefix(prefix)) => prefix,
715        _ => return None, // fail-closed: a path without a prefix has nothing to strip
716    };
717    let drive;
718    let (lead, head) = match prefix.kind() {
719        Prefix::VerbatimDisk(letter) => {
720            drive = format!("{}:", char::from(letter));
721            ("", vec![OsStr::new(&drive)])
722        }
723        Prefix::VerbatimUNC(server, share) => (r"\\", vec![server, share]),
724        _ => return None, // fail-closed: device, volume and non-verbatim prefixes keep their form
725    };
726    let rest: Vec<_> = components.collect();
727    let suffix_len = path.as_os_str().len() - prefix.as_os_str().len();
728    plain_verbatim_path(lead, &head, &rest, suffix_len)
729}
730
731/// The plain form `lead` + `head` + `\` + the `Normal` names of `rest` when it names the same
732/// file: every name passes `plain_name`, and its length equals the head plus `suffix_len` (the
733/// verbatim length after the prefix), which holds only when `rest` is one root followed by
734/// `\`-separated names, so no `.`, `..`, empty or trailing component was dropped. An empty suffix
735/// is a UNC share root (`\\server\share`); a drive has none, since plain `C:` names its current
736/// directory. Length is not
737/// limited: std re-adds the verbatim prefix to long paths, and a limit would leave a long path
738/// verbatim under a plain `Config::root`, failing containment. `None` keeps the verbatim form.
739#[cfg(any(windows, test))]
740fn plain_verbatim_path(
741    lead: &str,
742    head: &[&std::ffi::OsStr],
743    rest: &[Component<'_>],
744    suffix_len: usize,
745) -> Option<PathBuf> {
746    let names: Vec<_> = rest
747        .iter()
748        .filter_map(|component| match component {
749            Component::Normal(name) => Some(*name),
750            _ => None, // fail-closed: the length check rejects any dropped component but the root
751        })
752        .collect();
753    let mut plain = std::ffi::OsString::from(lead);
754    plain.push(join_with_separator(head));
755    let head_len = plain.len();
756    if suffix_len > 0 {
757        plain.push(r"\");
758        plain.push(join_with_separator(&names));
759    }
760    (plain.len() == head_len + suffix_len
761        && (suffix_len > 0 || !lead.is_empty())
762        && head.iter().all(|name| plain_name(name))
763        && names
764            .iter()
765            .all(|name| plain_name(name) && plain_chars(name)))
766    .then(|| plain.into())
767}
768
769/// `parts` joined with `\`, built without the unstable `OsStr` slice `join`.
770#[cfg(any(windows, test))]
771fn join_with_separator(parts: &[&std::ffi::OsStr]) -> std::ffi::OsString {
772    let mut joined = std::ffi::OsString::new();
773    for (index, part) in parts.iter().enumerate() {
774        if index > 0 {
775            joined.push(r"\");
776        }
777        joined.push(part);
778    }
779    joined
780}
781
782/// Whether `name` holds no character Win32 rejects or reinterprets: the reserved `<>:"|?*`
783/// (`:` starts a stream name) and control characters below U+0020. Checked on the `names` only;
784/// the head is a drive or server name the verbatim form already validated.
785#[cfg(any(windows, test))]
786fn plain_chars(name: &std::ffi::OsStr) -> bool {
787    name.as_encoded_bytes()
788        .iter()
789        .all(|&byte| byte >= 0x20 && !br#"<>:"|?*"#.contains(&byte))
790}
791
792/// Whether Win32 keeps `name` unchanged in a plain path: not a DOS device name (superscript
793/// aliases and console handles included), no trailing dot or space, no `/`. Device checks run on
794/// the encoded bytes, so names that are not valid Unicode are checked too; `plain_chars` covers
795/// the remaining characters.
796#[cfg(any(windows, test))]
797fn plain_name(name: &std::ffi::OsStr) -> bool {
798    // Reserved names from "Naming Files, Paths, and Namespaces", and the console handles from
799    // "CreateFileW".
800    const RESERVED: [&[u8]; 32] = [
801        b"CON",
802        b"CONIN$",
803        b"CONOUT$",
804        b"PRN",
805        b"AUX",
806        b"NUL",
807        b"COM0",
808        b"COM1",
809        b"COM2",
810        b"COM3",
811        b"COM4",
812        b"COM5",
813        b"COM6",
814        b"COM7",
815        b"COM8",
816        b"COM9",
817        "COM¹".as_bytes(),
818        "COM²".as_bytes(),
819        "COM³".as_bytes(),
820        b"LPT0",
821        b"LPT1",
822        b"LPT2",
823        b"LPT3",
824        b"LPT4",
825        b"LPT5",
826        b"LPT6",
827        b"LPT7",
828        b"LPT8",
829        b"LPT9",
830        "LPT¹".as_bytes(),
831        "LPT²".as_bytes(),
832        "LPT³".as_bytes(),
833    ];
834    let bytes = name.as_encoded_bytes();
835    let stem = bytes
836        .split(|&byte| byte == b'.')
837        .next()
838        .unwrap_or_default()
839        .trim_ascii_end();
840    let device = RESERVED
841        .iter()
842        .any(|reserved| stem.eq_ignore_ascii_case(reserved));
843    !bytes.ends_with(b".") && !bytes.ends_with(b" ") && !bytes.contains(&b'/') && !device
844}
845
846/// `path` canonicalized and normalized like `Config::root`, so paths reached through the
847/// filesystem compare with, and strip to relative paths under, the analysis root on every platform.
848pub(crate) fn canonical_platform_path(path: &Path) -> std::io::Result<PathBuf> {
849    path.canonicalize().map(normalize_platform_path)
850}
851
852fn read_raw_config(path: &Path) -> Result<RawConfig> {
853    let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
854    // serde_json's default recursion limit remains enabled for untrusted config input.
855    serde_json::from_str::<RawConfig>(&text)
856        .with_context(|| format!("failed to parse {}", path.display()))
857}
858
859fn discover_config_file(
860    root: &Path,
861    warnings: &mut Vec<String>,
862) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
863    const CANDIDATES: [&str; 4] = [
864        ".cuke-dedup.json",
865        ".config/cuke-dedup.json",
866        ".config/.cuke-dedup.json",
867        "cuke-dedup.config.json",
868    ];
869    for candidate in CANDIDATES {
870        let path = root.join(candidate);
871        if !path.is_file() {
872            continue;
873        }
874        match read_raw_config(&path) {
875            Ok(config) => return Ok((Some(config), Some(path))),
876            Err(error) if is_input_limit_error(&error) => return Err(error),
877            Err(error) => warnings.push(format!(
878                "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
879                path.display()
880            )),
881        }
882    }
883    Ok((None, None))
884}
885
886fn config_source_name(path: &Path, root: &Path) -> String {
887    path.strip_prefix(root)
888        .unwrap_or(path)
889        .to_string_lossy()
890        .replace('\\', "/")
891}
892
893#[cfg(test)]
894mod tests;