Skip to main content

tfparser_core/discovery/
options.rs

1//! Discovery configuration.
2//!
3//! Defaults pin the caps from [70-security.md § 3.2] and the exclude globs
4//! from [11-discovery.md § 2]. Callers either call [`DiscoveryOptions::defaults`]
5//! (covers every M0 fixture) or override fields via the generated
6//! [`DiscoveryOptionsBuilder`].
7//!
8//! [70-security.md § 3.2]: ../../../specs/70-security.md
9//! [11-discovery.md § 2]: ../../../specs/11-discovery.md
10
11use std::sync::Arc;
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use thiserror::Error;
15use typed_builder::TypedBuilder;
16
17/// Hard cap on the byte length of any user-supplied glob pattern, per
18/// [70-security.md § 3.4](../../../specs/70-security.md). Beyond this length
19/// regex compilation is rejected before reaching `globset`.
20pub const MAX_GLOB_PATTERN_BYTES: usize = 256;
21
22/// Cap on walker thread count. Per [11-discovery.md § 3.5], beyond 8 the fs
23/// metadata calls saturate on macOS APFS / Linux ext4. The current
24/// sequential walker uses one thread; the cap is reserved for the parallel
25/// implementation that lands in Phase 9.
26pub const MAX_DISCOVERY_THREADS: u32 = 8;
27
28/// Discovery configuration (caps, excludes, classification hints).
29///
30/// Build via the generated [`DiscoveryOptionsBuilder`].
31#[derive(Clone, Debug, TypedBuilder)]
32#[non_exhaustive]
33pub struct DiscoveryOptions {
34    /// Whether the walker follows symlinks. Default: `false`.
35    #[builder(default = false)]
36    pub follow_symlinks: bool,
37
38    /// Maximum walk depth, measured in directory levels below the workspace
39    /// root. Default: `16` per [70-security.md § 3.2].
40    #[builder(default = 16)]
41    pub max_depth: u32,
42
43    /// Maximum byte size for any individual file. Files exceeding this cap
44    /// are skipped with a [`crate::diagnostic::LimitKind::FileSize`]
45    /// diagnostic. Default: 8 MiB.
46    #[builder(default = 8 * 1024 * 1024)]
47    pub max_file_size_bytes: u64,
48
49    /// Workspace-wide cap on the total number of files visited. Breaching
50    /// this returns [`crate::Error::Limit`] (fatal). Default: `200_000` per
51    /// [70-security.md § 3.2].
52    #[builder(default = 200_000)]
53    pub max_total_files: u64,
54
55    /// Compiled exclude glob set (defaults plus user-supplied entries).
56    /// Anything matching is dropped from the walk *before* classification.
57    #[builder(default = default_exclude_globset())]
58    pub exclude_globs: Arc<GlobSet>,
59
60    /// Compiled module-glob set. A directory whose path matches is
61    /// pre-classified as a module before the file-content heuristics run
62    /// (per [11-discovery.md § 3.2 rule 1]).
63    #[builder(default = default_module_globset())]
64    pub module_globs: Arc<GlobSet>,
65
66    /// Number of walker threads (capped at [`MAX_DISCOVERY_THREADS`]). The
67    /// current sequential walker ignores this value but it is reserved for
68    /// the parallel implementation that lands in Phase 9.
69    #[builder(default = 0)]
70    pub threads: u32,
71}
72
73impl DiscoveryOptions {
74    /// Build the spec defaults: `max_depth=16`, `max_file_size=8 MiB`,
75    /// `max_total_files=200_000`, `follow_symlinks=false`, default
76    /// excludes (`.git`, `.terraform`, `.terragrunt-cache`), default module
77    /// globs (`modules/**`, `**/modules/*`).
78    #[must_use]
79    pub fn defaults() -> Self {
80        Self::builder().build()
81    }
82
83    /// The configured `threads` value clamped to [`MAX_DISCOVERY_THREADS`].
84    /// Used by the (forthcoming) parallel walker; surfaced now so callers
85    /// can introspect the effective value without re-implementing the cap.
86    #[must_use]
87    pub const fn effective_threads(&self) -> u32 {
88        if self.threads > MAX_DISCOVERY_THREADS {
89            MAX_DISCOVERY_THREADS
90        } else {
91            self.threads
92        }
93    }
94}
95
96impl Default for DiscoveryOptions {
97    fn default() -> Self {
98        Self::defaults()
99    }
100}
101
102/// Errors raised while compiling user-supplied glob patterns.
103#[derive(Debug, Error)]
104#[non_exhaustive]
105pub enum GlobConfigError {
106    /// Pattern exceeded [`MAX_GLOB_PATTERN_BYTES`]. Caller should reject the
107    /// input — this is a defence against adversarial regex `DoS` via the
108    /// underlying `globset` engine.
109    #[error("glob pattern too long ({observed} > {limit}): `{pattern}`")]
110    TooLong {
111        /// The pattern as supplied.
112        pattern: String,
113        /// Observed byte length.
114        observed: usize,
115        /// Configured cap.
116        limit: usize,
117    },
118
119    /// `globset` rejected the pattern as malformed.
120    #[error("invalid glob pattern `{pattern}`: {source}")]
121    Invalid {
122        /// The pattern as supplied.
123        pattern: String,
124        /// Underlying error.
125        #[source]
126        source: globset::Error,
127    },
128}
129
130/// Compile a user-supplied glob list into a [`GlobSet`], applying the length
131/// cap from [70-security.md § 3.4](../../../specs/70-security.md).
132///
133/// # Errors
134///
135/// [`GlobConfigError::TooLong`] when any pattern exceeds the cap.
136/// [`GlobConfigError::Invalid`] when `globset` rejects the syntax.
137pub fn compile_glob_set<I, S>(patterns: I) -> Result<GlobSet, GlobConfigError>
138where
139    I: IntoIterator<Item = S>,
140    S: AsRef<str>,
141{
142    let mut builder = GlobSetBuilder::new();
143    for pattern in patterns {
144        let pattern_str = pattern.as_ref();
145        if pattern_str.len() > MAX_GLOB_PATTERN_BYTES {
146            return Err(GlobConfigError::TooLong {
147                pattern: pattern_str.to_owned(),
148                observed: pattern_str.len(),
149                limit: MAX_GLOB_PATTERN_BYTES,
150            });
151        }
152        let glob = Glob::new(pattern_str).map_err(|source| GlobConfigError::Invalid {
153            pattern: pattern_str.to_owned(),
154            source,
155        })?;
156        builder.add(glob);
157    }
158    builder.build().map_err(|source| GlobConfigError::Invalid {
159        pattern: String::new(),
160        source,
161    })
162}
163
164/// Default exclude globs — pinned in [11-discovery.md § 2].
165fn default_exclude_globset() -> Arc<GlobSet> {
166    let patterns = [
167        "**/.git",
168        "**/.git/**",
169        "**/.terraform",
170        "**/.terraform/**",
171        "**/.terragrunt-cache",
172        "**/.terragrunt-cache/**",
173    ];
174    let set = compile_glob_set(patterns).unwrap_or_else(|_| GlobSet::empty());
175    Arc::new(set)
176}
177
178/// Default module globs — pinned in [11-discovery.md § 3.2].
179fn default_module_globset() -> Arc<GlobSet> {
180    let patterns = [
181        "modules/*",
182        "modules/**",
183        "modules-tf12/*",
184        "modules-tf12/**",
185        "**/modules/*",
186        "**/modules-tf12/*",
187    ];
188    let set = compile_glob_set(patterns).unwrap_or_else(|_| GlobSet::empty());
189    Arc::new(set)
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195    use std::path::Path;
196
197    use super::*;
198
199    #[test]
200    fn test_should_build_defaults() {
201        let opts = DiscoveryOptions::defaults();
202        assert!(!opts.follow_symlinks);
203        assert_eq!(opts.max_depth, 16);
204        assert_eq!(opts.max_file_size_bytes, 8 * 1024 * 1024);
205        assert_eq!(opts.max_total_files, 200_000);
206        assert!(opts.exclude_globs.is_match(Path::new(".git/HEAD")));
207        assert!(
208            opts.exclude_globs
209                .is_match(Path::new("services/order-service/.terraform/lockfile"))
210        );
211    }
212
213    #[test]
214    fn test_should_match_module_globs_by_default() {
215        let opts = DiscoveryOptions::defaults();
216        assert!(opts.module_globs.is_match(Path::new("modules/iam-role")));
217        assert!(
218            opts.module_globs
219                .is_match(Path::new("terraform/modules/vpc"))
220        );
221    }
222
223    #[test]
224    fn test_should_compile_user_glob_set() {
225        let set = compile_glob_set(["foo/**", "bar/*"]).unwrap();
226        assert!(set.is_match(Path::new("foo/x/y")));
227        assert!(set.is_match(Path::new("bar/x")));
228        assert!(!set.is_match(Path::new("baz")));
229    }
230
231    #[test]
232    fn test_should_reject_overlong_pattern() {
233        let pattern = "a".repeat(MAX_GLOB_PATTERN_BYTES + 1);
234        let err = compile_glob_set([pattern]).unwrap_err();
235        assert!(matches!(err, GlobConfigError::TooLong { .. }));
236    }
237
238    #[test]
239    fn test_should_reject_invalid_glob_pattern() {
240        let err = compile_glob_set(["[unterminated"]).unwrap_err();
241        assert!(matches!(err, GlobConfigError::Invalid { .. }));
242    }
243
244    #[test]
245    fn test_builder_applies_overrides() {
246        let opts: DiscoveryOptions = DiscoveryOptions::builder()
247            .max_depth(4_u32)
248            .follow_symlinks(true)
249            .threads(2_u32)
250            .build();
251        assert_eq!(opts.max_depth, 4);
252        assert!(opts.follow_symlinks);
253        assert_eq!(opts.threads, 2);
254    }
255}