1use crate::decision::Decision;
2use crate::error::Error;
3use crate::error::Result;
4use crate::policy::MatchOptions;
5use crate::policy::Policy;
6use codex_utils_absolute_path::AbsolutePathBuf;
7use serde::Deserialize;
8use serde::Serialize;
9use shlex::try_join;
10use std::any::Any;
11use std::fmt::Debug;
12use std::sync::Arc;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum PatternToken {
17 Single(String),
18 Alts(Vec<String>),
19}
20
21impl PatternToken {
22 fn matches(&self, token: &str) -> bool {
23 match self {
24 Self::Single(expected) => expected == token,
25 Self::Alts(alternatives) => alternatives.iter().any(|alt| alt == token),
26 }
27 }
28
29 pub fn alternatives(&self) -> &[String] {
30 match self {
31 Self::Single(expected) => std::slice::from_ref(expected),
32 Self::Alts(alternatives) => alternatives,
33 }
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct PrefixPattern {
41 pub first: Arc<str>,
42 pub rest: Arc<[PatternToken]>,
43}
44
45impl PrefixPattern {
46 pub fn matches_prefix(&self, cmd: &[String]) -> Option<Vec<String>> {
47 let pattern_length = self.rest.len() + 1;
48 if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() {
49 return None;
50 }
51
52 for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) {
53 if !pattern_token.matches(cmd_token) {
54 return None;
55 }
56 }
57
58 Some(cmd[..pattern_length].to_vec())
59 }
60}
61
62#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub enum RuleMatch {
65 PrefixRuleMatch {
66 #[serde(rename = "matchedPrefix")]
67 matched_prefix: Vec<String>,
68 decision: Decision,
69 #[serde(rename = "resolvedProgram", skip_serializing_if = "Option::is_none")]
70 resolved_program: Option<AbsolutePathBuf>,
71 #[serde(skip_serializing_if = "Option::is_none")]
76 justification: Option<String>,
77 },
78 HeuristicsRuleMatch {
79 command: Vec<String>,
80 decision: Decision,
81 },
82}
83
84impl RuleMatch {
85 pub fn decision(&self) -> Decision {
86 match self {
87 Self::PrefixRuleMatch { decision, .. } => *decision,
88 Self::HeuristicsRuleMatch { decision, .. } => *decision,
89 }
90 }
91
92 pub fn with_resolved_program(self, resolved_program: &AbsolutePathBuf) -> Self {
93 match self {
94 Self::PrefixRuleMatch {
95 matched_prefix,
96 decision,
97 justification,
98 ..
99 } => Self::PrefixRuleMatch {
100 matched_prefix,
101 decision,
102 resolved_program: Some(resolved_program.clone()),
103 justification,
104 },
105 other => other,
106 }
107 }
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct PrefixRule {
112 pub pattern: PrefixPattern,
113 pub decision: Decision,
114 pub justification: Option<String>,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub enum NetworkRuleProtocol {
119 Http,
120 Https,
121 Socks5Tcp,
122 Socks5Udp,
123}
124
125impl NetworkRuleProtocol {
126 pub fn parse(raw: &str) -> Result<Self> {
127 match raw {
128 "http" => Ok(Self::Http),
129 "https" | "https_connect" | "http-connect" => Ok(Self::Https),
130 "socks5_tcp" => Ok(Self::Socks5Tcp),
131 "socks5_udp" => Ok(Self::Socks5Udp),
132 other => Err(Error::InvalidRule(format!(
133 "network_rule protocol must be one of http, https, socks5_tcp, socks5_udp (got {other})"
134 ))),
135 }
136 }
137
138 pub fn as_policy_string(self) -> &'static str {
139 match self {
140 Self::Http => "http",
141 Self::Https => "https",
142 Self::Socks5Tcp => "socks5_tcp",
143 Self::Socks5Udp => "socks5_udp",
144 }
145 }
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub struct NetworkRule {
150 pub host: String,
151 pub protocol: NetworkRuleProtocol,
152 pub decision: Decision,
153 pub justification: Option<String>,
154}
155
156pub(crate) fn normalize_network_rule_host(raw: &str) -> Result<String> {
157 let mut host = raw.trim();
158 if host.is_empty() {
159 return Err(Error::InvalidRule(
160 "network_rule host cannot be empty".to_string(),
161 ));
162 }
163 if host.contains("://") || host.contains('/') || host.contains('?') || host.contains('#') {
164 return Err(Error::InvalidRule(
165 "network_rule host must be a hostname or IP literal (without scheme or path)"
166 .to_string(),
167 ));
168 }
169
170 if let Some(stripped) = host.strip_prefix('[') {
171 let Some((inside, rest)) = stripped.split_once(']') else {
172 return Err(Error::InvalidRule(
173 "network_rule host has an invalid bracketed IPv6 literal".to_string(),
174 ));
175 };
176 let port_ok = rest
177 .strip_prefix(':')
178 .is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()));
179 if !rest.is_empty() && !port_ok {
180 return Err(Error::InvalidRule(format!(
181 "network_rule host contains an unsupported suffix: {raw}"
182 )));
183 }
184 host = inside;
185 } else if host.matches(':').count() == 1
186 && let Some((candidate, port)) = host.rsplit_once(':')
187 && !candidate.is_empty()
188 && !port.is_empty()
189 && port.chars().all(|c| c.is_ascii_digit())
190 {
191 host = candidate;
192 }
193
194 let normalized = host.trim_end_matches('.').trim().to_ascii_lowercase();
195 if normalized.is_empty() {
196 return Err(Error::InvalidRule(
197 "network_rule host cannot be empty".to_string(),
198 ));
199 }
200 if normalized.contains('*') {
201 return Err(Error::InvalidRule(
202 "network_rule host must be a specific host; wildcards are not allowed".to_string(),
203 ));
204 }
205 if normalized.chars().any(char::is_whitespace) {
206 return Err(Error::InvalidRule(
207 "network_rule host cannot contain whitespace".to_string(),
208 ));
209 }
210
211 Ok(normalized)
212}
213
214pub trait Rule: Any + Debug + Send + Sync {
215 fn program(&self) -> &str;
216
217 fn matches(&self, cmd: &[String]) -> Option<RuleMatch>;
218
219 fn as_any(&self) -> &dyn Any;
220}
221
222pub type RuleRef = Arc<dyn Rule>;
223
224impl Rule for PrefixRule {
225 fn program(&self) -> &str {
226 self.pattern.first.as_ref()
227 }
228
229 fn matches(&self, cmd: &[String]) -> Option<RuleMatch> {
230 self.pattern
231 .matches_prefix(cmd)
232 .map(|matched_prefix| RuleMatch::PrefixRuleMatch {
233 matched_prefix,
234 decision: self.decision,
235 resolved_program: None,
236 justification: self.justification.clone(),
237 })
238 }
239
240 fn as_any(&self) -> &dyn Any {
241 self
242 }
243}
244
245pub(crate) fn validate_match_examples(
247 policy: &Policy,
248 rules: &[RuleRef],
249 matches: &[Vec<String>],
250) -> Result<()> {
251 let mut unmatched_examples = Vec::new();
252 let options = MatchOptions {
253 resolve_host_executables: true,
254 };
255
256 for example in matches {
257 if !policy
258 .matches_for_command_with_options(example, None, &options)
259 .is_empty()
260 {
261 continue;
262 }
263
264 unmatched_examples.push(
265 try_join(example.iter().map(String::as_str))
266 .unwrap_or_else(|_| "unable to render example".to_string()),
267 );
268 }
269
270 if unmatched_examples.is_empty() {
271 Ok(())
272 } else {
273 Err(Error::ExampleDidNotMatch {
274 rules: rules.iter().map(|rule| format!("{rule:?}")).collect(),
275 examples: unmatched_examples,
276 location: None,
277 })
278 }
279}
280
281pub(crate) fn validate_not_match_examples(
283 policy: &Policy,
284 _rules: &[RuleRef],
285 not_matches: &[Vec<String>],
286) -> Result<()> {
287 let options = MatchOptions {
288 resolve_host_executables: true,
289 };
290
291 for example in not_matches {
292 if let Some(rule) = policy
293 .matches_for_command_with_options(example, None, &options)
294 .first()
295 {
296 return Err(Error::ExampleDidMatch {
297 rule: format!("{rule:?}"),
298 example: try_join(example.iter().map(String::as_str))
299 .unwrap_or_else(|_| "unable to render example".to_string()),
300 location: None,
301 });
302 }
303 }
304
305 Ok(())
306}