lean_ctx/core/shell_allowlist/
tokenizer.rs1pub fn shell_tokenize(input: &str) -> Vec<String> {
5 let mut tokens = Vec::new();
6 let mut current = String::new();
7 let mut chars = input.chars().peekable();
8 let mut in_single = false;
9 let mut in_double = false;
10 let mut parameter_depth: u32 = 0;
11
12 while let Some(c) = chars.next() {
13 match c {
14 '\'' if !in_double => in_single = !in_single,
15 '"' if !in_single => in_double = !in_double,
16 '\\' if !in_single => {
17 if let Some(next) = chars.next() {
18 current.push(next);
19 }
20 }
21 '$' if !in_single && chars.peek() == Some(&'{') => {
22 parameter_depth += 1;
23 current.push(c);
24 }
25 '}' if !in_single && parameter_depth > 0 => {
26 parameter_depth -= 1;
27 current.push(c);
28 }
29 c if c.is_whitespace() && !in_single && !in_double && parameter_depth == 0 => {
30 if !current.is_empty() {
31 tokens.push(std::mem::take(&mut current));
32 }
33 }
34 _ => current.push(c),
35 }
36 }
37 if !current.is_empty() {
38 tokens.push(current);
39 }
40 tokens
41}
42
43pub(super) fn quote_aware_token_end(input: &str) -> usize {
52 let bytes = input.as_bytes();
53 let len = bytes.len();
54 let mut i = 0;
55 let mut in_single = false;
56 let mut in_double = false;
57 let mut paren_depth: u32 = 0;
58 let mut parameter_depth: u32 = 0;
59
60 while i < len {
61 let ch = bytes[i];
62 match ch {
63 b'\'' if !in_double => {
64 in_single = !in_single;
65 i += 1;
66 }
67 b'"' if !in_single => {
68 in_double = !in_double;
69 i += 1;
70 }
71 b'\\' if !in_single => {
72 i = (i + 2).min(len);
73 }
74 b'(' if !in_single && !in_double => {
75 paren_depth += 1;
76 i += 1;
77 }
78 b')' if !in_single && !in_double && paren_depth > 0 => {
79 paren_depth -= 1;
80 i += 1;
81 }
82 b'$' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'{') => {
83 parameter_depth += 1;
84 i += 1;
85 }
86 b'}' if !in_single && parameter_depth > 0 => {
87 parameter_depth -= 1;
88 i += 1;
89 }
90 b if b.is_ascii_whitespace()
91 && !in_single
92 && !in_double
93 && paren_depth == 0
94 && parameter_depth == 0 =>
95 {
96 return i;
97 }
98 _ => i += 1,
99 }
100 }
101 len
102}
103pub(super) fn extract_all_commands(command: &str) -> Vec<String> {
106 split_on_operators(command)
107 .into_iter()
108 .map(|s| s.trim().to_string())
109 .filter(|s| !s.is_empty())
110 .collect()
111}
112
113pub(super) fn split_on_operators(command: &str) -> Vec<&str> {
120 let mut segments = Vec::new();
121 let mut start = 0;
122 let bytes = command.as_bytes();
123 let len = bytes.len();
124 let mut i = 0;
125 let mut in_single_quote = false;
126 let mut in_double_quote = false;
127 let mut paren_depth: u32 = 0;
128 let mut brace_depth: u32 = 0;
133
134 while i < len {
135 let ch = bytes[i];
136
137 if in_single_quote {
138 if ch == b'\'' {
139 in_single_quote = false;
140 }
141 i += 1;
142 continue;
143 }
144
145 if in_double_quote {
146 match ch {
147 b'\\' => i = (i + 2).min(len),
149 b'"' => {
150 in_double_quote = false;
151 i += 1;
152 }
153 _ => i += 1,
154 }
155 continue;
156 }
157
158 match ch {
159 b'\\' => {
160 i = (i + 2).min(len);
163 }
164 b'\'' => {
165 in_single_quote = true;
166 i += 1;
167 }
168 b'"' => {
169 in_double_quote = true;
170 i += 1;
171 }
172 b'(' => {
173 paren_depth += 1;
174 i += 1;
175 }
176 b')' => {
177 paren_depth = paren_depth.saturating_sub(1);
178 i += 1;
179 }
180 b'{' => {
181 brace_depth += 1;
182 i += 1;
183 }
184 b'}' => {
185 brace_depth = brace_depth.saturating_sub(1);
186 i += 1;
187 }
188 b'\n' | b'\r' | b';' if paren_depth == 0 && brace_depth == 0 => {
189 segments.push(&command[start..i]);
190 i += 1;
191 start = i;
192 }
193 b'&' if paren_depth == 0 && brace_depth == 0 => {
194 if i + 1 < len && bytes[i + 1] == b'&' {
195 segments.push(&command[start..i]);
197 i += 2;
198 start = i;
199 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
200 i += 1;
205 } else {
206 segments.push(&command[start..i]);
208 i += 1;
209 start = i;
210 }
211 }
212 b'|' if paren_depth == 0 && brace_depth == 0 => {
213 if i + 1 < len && bytes[i + 1] == b'|' {
214 segments.push(&command[start..i]);
216 i += 2;
217 start = i;
218 } else if i > 0 && bytes[i - 1] == b'>' {
219 i += 1;
225 } else {
226 segments.push(&command[start..i]);
228 i += 1;
229 start = i;
230 }
231 }
232 _ => {
233 i += 1;
234 }
235 }
236 }
237
238 if start < len {
239 segments.push(&command[start..]);
240 }
241
242 segments
243}
244
245pub(super) fn extract_base_from_segment(segment: &str) -> String {
247 let trimmed = segment.trim();
248 if trimmed.is_empty() {
249 return String::new();
250 }
251
252 let cmd_part = skip_env_assignments(trimmed);
253 if cmd_part.is_empty() {
254 return String::new();
255 }
256
257 let tokens = shell_tokenize(cmd_part);
258 let mut token_iter = tokens.iter();
263 let first_token = match token_iter.next().map(String::as_str) {
264 Some("{") => token_iter.next().map_or("", String::as_str),
265 other => other.unwrap_or(""),
266 };
267
268 first_token
269 .rsplit('/')
270 .next()
271 .unwrap_or(first_token)
272 .to_string()
273}
274
275pub(super) fn skip_env_assignments(segment: &str) -> &str {
279 let mut rest = segment;
280 loop {
281 let rest_trimmed = rest.trim_start();
282 if rest_trimmed.is_empty() {
283 return rest_trimmed;
284 }
285 let end = quote_aware_token_end(rest_trimmed);
286 if end == 0 {
287 return rest_trimmed;
288 }
289 let raw_token = &rest_trimmed[..end];
290 let unquoted: String = raw_token
291 .chars()
292 .filter(|c| *c != '"' && *c != '\'')
293 .collect();
294 if unquoted.contains('=')
295 && !unquoted.starts_with('-')
296 && !unquoted.starts_with('/')
297 && !unquoted.starts_with('.')
298 {
299 rest = &rest_trimmed[end..];
300 } else {
301 return rest_trimmed;
302 }
303 }
304}
305pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
307 extract_all_commands(command)
308}
309pub fn extract_base_command(command: &str) -> String {
311 let first_seg = split_on_operators(command)
312 .into_iter()
313 .next()
314 .unwrap_or(command);
315 extract_base_from_segment(first_seg)
316}