double_o/pattern/toml.rs
1use regex::Regex;
2use regex::RegexBuilder;
3use serde::Deserialize;
4use std::path::Path;
5
6use super::{FailurePattern, FailureStrategy, Pattern, SuccessPattern, SuccessStrategy};
7use crate::error::Error;
8
9// ---------------------------------------------------------------------------
10// Regex validation limits
11// ---------------------------------------------------------------------------
12
13/// Maximum allowed length for user-provided regex patterns.
14///
15/// This limit prevents overly complex regex patterns that could cause
16/// performance issues or unexpected ReDOS attacks.
17const MAX_REGEX_LENGTH: usize = 500;
18
19/// Size limit for regex compilation (in bytes).
20///
21/// Prevents pathological regex patterns from consuming excessive memory.
22/// Set to 100 KB - ample for all reasonable patterns while still limiting ReDOS risk.
23const REGEX_SIZE_LIMIT: usize = 100 * 1024; // 100 KB
24
25/// Validate and compile a user-provided regex string with safety limits.
26///
27/// This function checks that the regex string is not overly long and compiles
28/// it with a reasonable size limit to prevent resource exhaustion issues.
29///
30/// # Arguments
31///
32/// * `pattern` - The regex pattern string to compile
33///
34/// # Errors
35///
36/// Returns `Error::Pattern` if the regex is too long or fails to compile.
37fn validate_and_compile_regex(pattern: &str) -> Result<Regex, Error> {
38 if pattern.len() > MAX_REGEX_LENGTH {
39 return Err(Error::Pattern(format!(
40 "regex too long ({} > {} chars)",
41 pattern.len(),
42 MAX_REGEX_LENGTH
43 )));
44 }
45
46 RegexBuilder::new(pattern)
47 .size_limit(REGEX_SIZE_LIMIT)
48 .build()
49 .map_err(|e| Error::Pattern(format!("regex compilation failed: {e}")))
50}
51
52// ---------------------------------------------------------------------------
53// TOML deserialization types
54// ---------------------------------------------------------------------------
55
56// ---------------------------------------------------------------------------
57// TOML deserialization types
58// ---------------------------------------------------------------------------
59
60/// TOML representation of a pattern file.
61///
62/// This struct deserializes from user-defined TOML pattern files
63/// loaded from the user patterns directory (see `oo patterns` for the path).
64/// Each file defines a single pattern with optional success and failure configurations.
65#[derive(Deserialize)]
66pub struct PatternFile {
67 /// Regex that matches the command line.
68 pub command_match: String,
69
70 /// Optional success pattern configuration.
71 pub success: Option<SuccessSection>,
72
73 /// Optional failure pattern configuration.
74 pub failure: Option<FailureSection>,
75}
76
77/// TOML configuration for success output extraction.
78///
79/// Supports both legacy pattern+summary format and new strategy-based format.
80#[derive(Deserialize)]
81pub struct SuccessSection {
82 /// Strategy name: "regex" (legacy), "tail", "head", or "grep".
83 #[serde(default)]
84 pub(crate) strategy: Option<String>,
85
86 /// Regex pattern with named capture groups (for legacy format or grep strategy).
87 #[serde(rename = "pattern")]
88 pub(crate) success_pattern: Option<String>,
89
90 /// Summary template with {name} placeholders (for legacy format).
91 pub(crate) summary: Option<String>,
92
93 /// Number of lines (for tail/head strategies).
94 pub(crate) lines: Option<usize>,
95
96 /// Grep pattern (for grep strategy).
97 #[serde(rename = "grep")]
98 pub(crate) grep_pattern: Option<String>,
99}
100
101/// TOML configuration for failure output filtering.
102///
103/// Defines how to extract relevant error information from failed command output.
104/// Multiple strategies are supported: tail, head, grep, and between.
105#[derive(Deserialize)]
106pub struct FailureSection {
107 /// Strategy name: "tail", "head", "grep", or "between".
108 pub(crate) strategy: Option<String>,
109
110 /// Number of lines (for tail/head strategies).
111 pub(crate) lines: Option<usize>,
112
113 /// Grep pattern (for grep strategy).
114 #[serde(rename = "grep")]
115 pub(crate) grep_pattern: Option<String>,
116
117 /// Start delimiter (for between strategy).
118 pub(crate) start: Option<String>,
119
120 /// End delimiter (for between strategy).
121 pub(crate) end: Option<String>,
122}
123
124// ---------------------------------------------------------------------------
125// User patterns (TOML on disk)
126// ---------------------------------------------------------------------------
127
128/// Classify a `*.toml` path before it is opened: `Ok(true)` for a regular
129/// file (or a symlink to one), `Ok(false)` for any other file type (directory,
130/// FIFO, socket, device, dangling symlink), `Err` for other metadata errors.
131///
132/// Metadata follows symlinks (`std::fs::metadata`), so the check must happen
133/// before any open: a FIFO or a symlink to `/dev/zero` would otherwise block
134/// or grow without end. The metadata-then-open order has a TOCTOU window;
135/// it is accepted because replacing a pattern file requires write access to
136/// the user's own patterns directory.
137pub(crate) fn is_pattern_file(path: &Path) -> std::io::Result<bool> {
138 match std::fs::metadata(path) {
139 Ok(m) => Ok(m.is_file()),
140 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
141 Err(e) => Err(e),
142 }
143}
144
145/// Load user-defined patterns from a directory of TOML files.
146///
147/// Invalid files are silently skipped. Non-regular entries (directories,
148/// FIFOs, sockets, devices, dangling symlinks) are skipped without being
149/// opened; a symlink to a regular file is read.
150pub fn load_user_patterns(dir: &Path) -> Vec<Pattern> {
151 let entries = match std::fs::read_dir(dir) {
152 Ok(e) => e,
153 Err(_) => return Vec::new(),
154 };
155
156 let mut patterns = Vec::new();
157 for entry in entries.flatten() {
158 let path = entry.path();
159 if path.extension().is_some_and(|e| e == "toml")
160 && is_pattern_file(&path).is_ok_and(|ok| ok)
161 {
162 if let Ok(p) = load_pattern_file(&path) {
163 patterns.push(p);
164 }
165 }
166 }
167 patterns
168}
169
170fn load_pattern_file(path: &Path) -> Result<Pattern, Error> {
171 let content =
172 std::fs::read_to_string(path).map_err(|e| Error::Pattern(format!("{path:?}: {e}")))?;
173 parse_pattern_str(&content).map_err(|e| {
174 // Add file path context to any parse errors
175 if let Error::Pattern(msg) = e {
176 Error::Pattern(format!("{path:?}: {msg}"))
177 } else {
178 e
179 }
180 })
181}
182
183/// Parse a pattern definition from TOML string content.
184///
185/// Deserializes a TOML pattern definition into a `Pattern` struct,
186/// validating regex patterns and strategy configurations.
187///
188/// # Arguments
189///
190/// * `content` - TOML-formatted pattern definition
191///
192/// # Returns
193///
194/// A `Pattern` struct if parsing and validation succeed, or an `Error`
195/// if TOML is malformed, regex is invalid, or strategy configuration is incomplete.
196///
197/// # Errors
198///
199/// Returns `Error::Pattern` for:
200/// - TOML parsing failures
201/// - Invalid regular expressions
202/// - Missing required fields (e.g., grep pattern for grep strategy)
203/// - Unknown strategy names
204/// - Regex patterns exceeding maximum length (500 characters)
205///
206/// # Examples
207///
208/// ```
209/// use double_o::pattern::parse_pattern_str;
210///
211/// let toml = r#"
212/// command_match = "myapp test"
213///
214/// [success]
215/// pattern = "(?P<passed>\\d+) passed"
216/// summary = "{passed} tests passed"
217/// "#;
218/// let pattern = parse_pattern_str(toml).unwrap();
219/// ```
220pub fn parse_pattern_str(content: &str) -> Result<Pattern, Error> {
221 let pf: PatternFile =
222 toml::from_str(content).map_err(|e| Error::Pattern(format!("TOML parse: {e}")))?;
223
224 // Validate and compile command_match regex with safety limits
225 let command_match = validate_and_compile_regex(&pf.command_match)?;
226
227 let success = pf
228 .success
229 .map(|s| -> Result<SuccessPattern, Error> {
230 // Determine strategy: explicit strategy field, or default to "regex" for legacy format
231 let strategy = match s.strategy.as_deref().unwrap_or("regex") {
232 "tail" => SuccessStrategy::Tail {
233 lines: s.lines.unwrap_or(30),
234 },
235 "head" => SuccessStrategy::Head {
236 lines: s.lines.unwrap_or(20),
237 },
238 "grep" => {
239 let pat = s.grep_pattern.ok_or_else(|| {
240 Error::Pattern("grep strategy requires 'grep' field".into())
241 })?;
242 let pattern = validate_and_compile_regex(&pat)?;
243 SuccessStrategy::Grep { pattern }
244 }
245 "regex" => {
246 // Legacy format: pattern + summary
247 let pattern = s.success_pattern.ok_or_else(|| {
248 Error::Pattern("regex strategy requires 'pattern' field".into())
249 })?;
250 let summary = s.summary.ok_or_else(|| {
251 Error::Pattern("regex strategy requires 'summary' field".into())
252 })?;
253 let regex = validate_and_compile_regex(&pattern)?;
254 SuccessStrategy::Regex {
255 pattern: regex,
256 summary,
257 }
258 }
259 other => {
260 return Err(Error::Pattern(format!("unknown success strategy: {other}")));
261 }
262 };
263 Ok(SuccessPattern { strategy })
264 })
265 .transpose()?;
266
267 let failure = pf
268 .failure
269 .map(|f| -> Result<FailurePattern, Error> {
270 let strategy = match f.strategy.as_deref().unwrap_or("tail") {
271 "tail" => FailureStrategy::Tail {
272 lines: f.lines.unwrap_or(30),
273 },
274 "head" => FailureStrategy::Head {
275 lines: f.lines.unwrap_or(20),
276 },
277 "grep" => {
278 let pat = f.grep_pattern.ok_or_else(|| {
279 Error::Pattern("grep strategy requires 'grep' field".into())
280 })?;
281 let pattern = validate_and_compile_regex(&pat)?;
282 FailureStrategy::Grep { pattern }
283 }
284 "between" => {
285 let start = f.start.ok_or_else(|| {
286 Error::Pattern("between strategy requires 'start'".into())
287 })?;
288 let end = f
289 .end
290 .ok_or_else(|| Error::Pattern("between strategy requires 'end'".into()))?;
291 FailureStrategy::Between { start, end }
292 }
293 other => {
294 return Err(Error::Pattern(format!("unknown strategy: {other}")));
295 }
296 };
297 Ok(FailurePattern { strategy })
298 })
299 .transpose()?;
300
301 Ok(Pattern {
302 command_match,
303 success,
304 failure,
305 })
306}
307
308#[cfg(test)]
309#[path = "toml_tests.rs"]
310mod tests;
311
312/// Validate all regexes in a TOML pattern string with safety limits.
313///
314/// This is used by the learn module to ensure LLM-generated patterns
315/// pass the same validation as manually-written TOML patterns.
316///
317/// # Errors
318///
319/// Returns `Error::Pattern` if TOML is malformed, regex is invalid,
320/// or strategy configuration is incomplete.
321pub fn validate_pattern_regexes(toml_str: &str) -> Result<(), Error> {
322 #[derive(Deserialize)]
323 struct Check {
324 command_match: String,
325 #[serde(default)]
326 success: Option<SuccessSection>,
327 #[serde(default)]
328 failure: Option<FailureSection>,
329 }
330
331 let check: Check =
332 toml::from_str(toml_str).map_err(|e| Error::Pattern(format!("TOML parse: {e}")))?;
333
334 // Validate command_match regex
335 validate_and_compile_regex(&check.command_match)?;
336
337 // Validate success regex if present
338 if let Some(ref s) = check.success {
339 match s.strategy.as_deref().unwrap_or("regex") {
340 "tail" | "head" => {} // no regex to validate
341 "grep" => {
342 let pat = s
343 .grep_pattern
344 .as_ref()
345 .ok_or_else(|| Error::Pattern("grep strategy requires 'grep' field".into()))?;
346 if pat.is_empty() {
347 return Err(Error::Pattern("grep regex must not be empty".into()));
348 }
349 validate_and_compile_regex(pat)?;
350 }
351 "regex" => {
352 let pattern = s.success_pattern.as_ref().ok_or_else(|| {
353 Error::Pattern("regex strategy requires 'pattern' field".into())
354 })?;
355 validate_and_compile_regex(pattern)?;
356 }
357 other => return Err(Error::Pattern(format!("unknown success strategy: {other}"))),
358 }
359 }
360
361 // Validate failure regex if present
362 if let Some(ref f) = check.failure {
363 match f.strategy.as_deref().unwrap_or("tail") {
364 "tail" | "head" => {} // no regex to validate
365 "grep" => {
366 let pat = f
367 .grep_pattern
368 .as_ref()
369 .ok_or_else(|| Error::Pattern("grep strategy requires 'grep' field".into()))?;
370 if pat.is_empty() {
371 return Err(Error::Pattern("grep regex must not be empty".into()));
372 }
373 validate_and_compile_regex(pat)?;
374 }
375 "between" => {
376 let start = f.start.as_ref().ok_or_else(|| {
377 Error::Pattern("between strategy requires 'start' field".into())
378 })?;
379 let end = f.end.as_ref().ok_or_else(|| {
380 Error::Pattern("between strategy requires 'end' field".into())
381 })?;
382 if start.is_empty() {
383 return Err(Error::Pattern("between 'start' must not be empty".into()));
384 }
385 if end.is_empty() {
386 return Err(Error::Pattern("between 'end' must not be empty".into()));
387 }
388 validate_and_compile_regex(start)?;
389 validate_and_compile_regex(end)?;
390 }
391 other => return Err(Error::Pattern(format!("unknown failure strategy: {other}"))),
392 }
393 }
394
395 Ok(())
396}