Skip to main content

clash_policy/
error.rs

1//! Unified error types for the policy subsystem.
2
3/// Error during policy parsing (YAML, rule strings, expressions).
4#[derive(Debug, thiserror::Error)]
5pub enum PolicyParseError {
6    #[error("YAML parse error: {0}")]
7    Yaml(#[from] serde_yaml::Error),
8
9    #[error("invalid rule '{rule}': {message}")]
10    InvalidRule { rule: String, message: String },
11
12    #[error("invalid effect '{0}'")]
13    InvalidEffect(String),
14
15    #[error("invalid tool '{0}'")]
16    InvalidTool(String),
17
18    #[error("invalid filter expression: {0}")]
19    InvalidFilter(String),
20
21    #[error("invalid profile expression: {0}")]
22    InvalidProfile(String),
23
24    #[error("unknown constraint or profile '{0}'")]
25    UnknownRef(String),
26
27    #[error("circular profile include: {cycle}")]
28    CircularInclude {
29        /// The profile name where the cycle was detected.
30        cycle: String,
31        /// The full include path showing the cycle (e.g. "a -> b -> c -> a").
32        path: Option<String>,
33    },
34
35    #[error("unknown profile '{name}' in include{}", .suggestion.as_ref().map(|s| format!("; did you mean '{}'?", s)).unwrap_or_default())]
36    UnknownInclude {
37        name: String,
38        /// Suggested closest match, if any.
39        suggestion: Option<String>,
40    },
41
42    #[error("invalid new-format rule key '{0}': {1}")]
43    InvalidNewRuleKey(String, String),
44
45    #[error("invalid cap-scoped fs key '{0}': {1}")]
46    InvalidCapScopedFs(String, String),
47
48    #[error("invalid args entry: {0}")]
49    InvalidArg(String),
50}
51
52impl PolicyParseError {
53    /// Return a help message suggesting how to fix this error, if applicable.
54    pub fn help(&self) -> Option<String> {
55        match self {
56            PolicyParseError::InvalidEffect(eff) => Some(format!(
57                "valid effects are: allow, deny, ask (got '{}')",
58                eff
59            )),
60            PolicyParseError::InvalidTool(tool) => Some(format!(
61                "any tool name is valid (bash, read, write, edit, task, glob, etc.) or * for wildcard (got '{}')",
62                tool
63            )),
64            PolicyParseError::InvalidRule { rule, .. } => Some(format!(
65                "expected format: 'effect entity tool pattern [: constraint]' (got '{}')",
66                rule
67            )),
68            PolicyParseError::CircularInclude { path, .. } => {
69                path.as_ref().map(|p| format!("include cycle: {}", p))
70            }
71            PolicyParseError::InvalidFilter(_) => Some(
72                "valid filter functions: subpath(path), literal(path), regex(pattern); \
73                 combine with & (and), | (or), ! (not)"
74                    .into(),
75            ),
76            PolicyParseError::InvalidProfile(_) => Some(
77                "profile expressions reference constraint or profile names; \
78                 combine with & (and), | (or), ! (not)"
79                    .into(),
80            ),
81            PolicyParseError::InvalidNewRuleKey(_, _) => {
82                Some("format: \"effect verb noun\" where effect=allow|deny|ask, verb=bash|read|write|edit|*, noun=command or path pattern. Example: \"allow bash git *\"".into())
83            }
84            _ => None,
85        }
86    }
87}
88
89/// Error during policy compilation.
90#[derive(Debug, thiserror::Error)]
91pub enum CompileError {
92    #[error("invalid glob pattern '{pattern}': {source}")]
93    InvalidGlob {
94        pattern: String,
95        source: regex::Error,
96    },
97    #[error("invalid regex in filter '{pattern}': {source}")]
98    InvalidFilterRegex {
99        pattern: String,
100        source: regex::Error,
101    },
102    #[error("profile flattening error: {0}")]
103    ProfileError(String),
104}
105
106impl CompileError {
107    /// Return a help message suggesting how to fix this error, if applicable.
108    pub fn help(&self) -> Option<String> {
109        match self {
110            CompileError::InvalidGlob { pattern, .. } => Some(format!(
111                "check glob pattern '{}': use * for single segment, ** for recursive, ? for single char",
112                pattern
113            )),
114            CompileError::InvalidFilterRegex { pattern, .. } => Some(format!(
115                "check regex '{}': special characters like (, ), [, ] need escaping with \\",
116                pattern
117            )),
118            CompileError::ProfileError(_) => {
119                Some("check profile definitions for missing or circular includes".into())
120            }
121        }
122    }
123}
124
125/// Unified policy error wrapping parse and compile errors.
126#[derive(Debug, thiserror::Error)]
127pub enum PolicyError {
128    #[error(transparent)]
129    Parse(#[from] PolicyParseError),
130    #[error(transparent)]
131    Compile(#[from] CompileError),
132}
133
134/// Compute Levenshtein edit distance between two strings.
135pub fn levenshtein(a: &str, b: &str) -> usize {
136    let a_len = a.len();
137    let b_len = b.len();
138
139    if a_len == 0 {
140        return b_len;
141    }
142    if b_len == 0 {
143        return a_len;
144    }
145
146    let mut prev: Vec<usize> = (0..=b_len).collect();
147    let mut curr = vec![0; b_len + 1];
148
149    for (i, ca) in a.chars().enumerate() {
150        curr[0] = i + 1;
151        for (j, cb) in b.chars().enumerate() {
152            let cost = if ca == cb { 0 } else { 1 };
153            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
154        }
155        std::mem::swap(&mut prev, &mut curr);
156    }
157
158    prev[b_len]
159}
160
161/// Find the closest match to `name` from a set of `candidates`.
162/// Returns `None` if no candidate is within a reasonable edit distance (max 3).
163pub fn suggest_closest(name: &str, candidates: &[&str]) -> Option<String> {
164    candidates
165        .iter()
166        .map(|c| (c, levenshtein(name, c)))
167        .filter(|(_, dist)| *dist <= 3 && *dist > 0)
168        .min_by_key(|(_, dist)| *dist)
169        .map(|(c, _)| (*c).to_string())
170}