Skip to main content

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}