lean_ctx/core/shell_allowlist/tokenizer.rs
1/// Tokenize a shell command segment respecting single/double quotes and backslash escapes.
2/// Returns tokens with outer quotes stripped, matching how the shell would parse them.
3/// E.g. `git -C "Program Files" status` → `["git", "-C", "Program Files", "status"]`
4pub 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 // #1781: inside double quotes a backslash is literal unless it
18 // precedes `$`, `` ` ``, `"`, `\` or a newline (POSIX 2.2.3).
19 // The old arm consumed it unconditionally, so a quoted Windows
20 // path — `Start-Process "C:\Program Files\Docker\Docker Desktop.exe"`
21 // — tokenized as `C:Program FilesDockerDocker Desktop.exe`, and
22 // the allowlist then refused a fragment that was never a command
23 // and asked the user to report it. Outside quotes the escape
24 // still applies to any character, which `tokenize_backslash_escape`
25 // pins.
26 let escapes_next =
27 !in_double || matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n'));
28 if escapes_next {
29 if let Some(next) = chars.next() {
30 current.push(next);
31 }
32 } else {
33 current.push('\\');
34 }
35 }
36 '$' if !in_single && chars.peek() == Some(&'{') => {
37 parameter_depth += 1;
38 current.push(c);
39 }
40 '}' if !in_single && parameter_depth > 0 => {
41 parameter_depth -= 1;
42 current.push(c);
43 }
44 c if c.is_whitespace() && !in_single && !in_double && parameter_depth == 0 => {
45 if !current.is_empty() {
46 tokens.push(std::mem::take(&mut current));
47 }
48 }
49 _ => current.push(c),
50 }
51 }
52 if !current.is_empty() {
53 tokens.push(current);
54 }
55 tokens
56}
57
58/// Returns the byte length of the first shell token in `input`, respecting quotes
59/// and `(...)` nesting. Used by `skip_env_assignments` to advance past env
60/// assignments with quoted values like `FOO="bar baz"` — and, critically, past
61/// assignments whose value is a command substitution like `FOO=$(cmd a b)`
62/// (#855): without paren-depth tracking, whitespace *inside* the unclosed
63/// `$(...)` looked like the end of the token, splitting `s=$(gh pr view …)`
64/// into a bogus token `s=$(gh` plus a leftover `pr` that got misread as the
65/// base command.
66pub(super) fn quote_aware_token_end(input: &str) -> usize {
67 let bytes = input.as_bytes();
68 let len = bytes.len();
69 let mut i = 0;
70 let mut in_single = false;
71 let mut in_double = false;
72 let mut paren_depth: u32 = 0;
73 let mut parameter_depth: u32 = 0;
74
75 while i < len {
76 let ch = bytes[i];
77 match ch {
78 b'\'' if !in_double => {
79 in_single = !in_single;
80 i += 1;
81 }
82 b'"' if !in_single => {
83 in_double = !in_double;
84 i += 1;
85 }
86 b'\\' if !in_single => {
87 i = (i + 2).min(len);
88 }
89 b'(' if !in_single && !in_double => {
90 paren_depth += 1;
91 i += 1;
92 }
93 b')' if !in_single && !in_double && paren_depth > 0 => {
94 paren_depth -= 1;
95 i += 1;
96 }
97 b'$' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'{') => {
98 parameter_depth += 1;
99 i += 1;
100 }
101 b'}' if !in_single && parameter_depth > 0 => {
102 parameter_depth -= 1;
103 i += 1;
104 }
105 b if b.is_ascii_whitespace()
106 && !in_single
107 && !in_double
108 && paren_depth == 0
109 && parameter_depth == 0 =>
110 {
111 return i;
112 }
113 _ => i += 1,
114 }
115 }
116 len
117}
118/// Extract ALL command segments from a compound shell command.
119/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
120pub(super) fn extract_all_commands(command: &str) -> Vec<String> {
121 split_on_operators(command)
122 .into_iter()
123 .map(|s| s.trim().to_string())
124 .filter(|s| !s.is_empty())
125 .collect()
126}
127
128/// One lexical context.
129///
130/// A command substitution starts a **fresh** one. POSIX says quoting restarts
131/// inside `$( … )`, so in `echo "$(python3 -c "a;b")"` the inner `"…"` is its
132/// own string and its `;` separates nothing (GH #1646). Tracking quotes as two
133/// flat booleans could not express that: the scanner never noticed `$(` while
134/// inside double quotes, so the inner opening quote *closed* the outer one,
135/// `a;b` looked unquoted, and the `;` split a bogus command out of a Python
136/// source line — which the allowlist then rejected with an unusable suggestion.
137///
138/// Treating the substitution as one word here loses no enforcement: its real
139/// contents are re-split and checked by `substitution::extract_substitution_commands`,
140/// which is where commands that a substitution genuinely *runs* are caught.
141#[derive(Clone, Copy)]
142struct Frame {
143 in_single_quote: bool,
144 in_double_quote: bool,
145 paren_depth: u32,
146 /// #939: brace groups (`{ cmd; }`) need the same operator-shielding as
147 /// `( cmd )` subshells — otherwise a `}` that closes a `{` opened on an
148 /// earlier physical line (e.g. after heredoc-body stripping collapses the
149 /// body between them) is misread as its own bare command segment.
150 brace_depth: u32,
151 /// #1793: `[[ … ]]` is a bash conditional *construct*, not a command, and
152 /// the `&&`/`||` inside it join predicates rather than commands. Without
153 /// shielding, `[[ -x a && -x b ]]` was split mid-condition into fragments
154 /// like `[[ -x a` and `-x b ]]`, none of which resolve to a real command.
155 bracket_depth: u32,
156 kind: FrameKind,
157}
158
159#[derive(Clone, Copy, PartialEq, Eq)]
160enum FrameKind {
161 /// The command line itself. Separators split only at this level.
162 Base,
163 /// Opened by `$(`, closed by the matching `)`.
164 DollarParen,
165 /// Opened and closed by a backtick — the older substitution form, same
166 /// quoting-restart rule.
167 Backtick,
168}
169
170impl Frame {
171 const fn new(kind: FrameKind) -> Self {
172 Self {
173 in_single_quote: false,
174 in_double_quote: false,
175 paren_depth: 0,
176 brace_depth: 0,
177 bracket_depth: 0,
178 kind,
179 }
180 }
181
182 /// Inside quotes, or inside a group whose closing delimiter is still open.
183 const fn shields_operators(&self) -> bool {
184 self.in_single_quote
185 || self.in_double_quote
186 || self.paren_depth > 0
187 || self.brace_depth > 0
188 || self.bracket_depth > 0
189 }
190}
191
192/// True when the two bytes at `i` form a standalone `[[` token — the opener of
193/// a bash conditional (#1793).
194///
195/// Bash requires `[[` to be its own word, and so does this: a word boundary
196/// before and whitespace after. That distinction is what keeps a glob character
197/// class (`ls a[[:alpha:]]`) or a nested array subscript (`${arr[[i]]}`) from
198/// opening a depth that would never close and swallow the rest of the line.
199fn opens_double_bracket(bytes: &[u8], i: usize) -> bool {
200 if bytes.get(i) != Some(&b'[') || bytes.get(i + 1) != Some(&b'[') {
201 return false;
202 }
203 let boundary_before = i == 0
204 || matches!(
205 bytes[i - 1],
206 b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b'('
207 );
208 let space_after = matches!(bytes.get(i + 2), Some(b' ' | b'\t' | b'\n' | b'\r'));
209 boundary_before && space_after
210}
211
212/// True when the two bytes at `i` form a standalone `]]` token closing a
213/// conditional opened by [`opens_double_bracket`]. Mirrors its word-boundary
214/// rule: whitespace before, and a separator or end of input after.
215fn closes_double_bracket(bytes: &[u8], i: usize) -> bool {
216 if bytes.get(i) != Some(&b']') || bytes.get(i + 1) != Some(&b']') {
217 return false;
218 }
219 let space_before = i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\n' | b'\r');
220 let separator_after = matches!(
221 bytes.get(i + 2),
222 None | Some(b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b')')
223 );
224 space_before && separator_after
225}
226
227/// Split command string on shell operators: ;, &&, ||, |
228/// Respects single/double quotes, parentheses nesting, and backslash escapes
229/// outside single quotes (GL #1160): `rg split\.label\|quantityLabel` is ONE
230/// command — the escaped pipe is regex data, not an operator. The old scanner
231/// split there and blocked the pattern fragment as an unknown command; same
232/// for `find … -exec rm {} \;`.
233pub(super) fn split_on_operators(command: &str) -> Vec<&str> {
234 split_with_separators(command)
235 .into_iter()
236 .map(|(segment, _)| segment)
237 .collect()
238}
239
240/// The operator that ends a command segment.
241///
242/// Where a segment runs can depend on it (#1850): a `cd` followed by `&&`
243/// moves the next command, one followed by `||` only runs the next command if
244/// it failed, and one in a pipeline or in the background moves nothing at all.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum Separator {
247 /// `;`, a newline or a carriage return.
248 Sequence,
249 /// `&&`
250 And,
251 /// `||`
252 Or,
253 /// `|`
254 Pipe,
255 /// A single `&`.
256 Background,
257}
258
259/// Every segment together with the separator that ends it; the last segment
260/// has none. Same scanner as `split_on_operators`, so the two never disagree
261/// about where a command ends.
262pub fn segments_with_separators(command: &str) -> Vec<(String, Option<Separator>)> {
263 split_with_separators(command)
264 .into_iter()
265 .map(|(segment, sep)| (segment.trim().to_string(), sep))
266 .filter(|(segment, _)| !segment.is_empty())
267 .collect()
268}
269
270/// The top-level statements of a command line: the original text between
271/// `;`/newline separators, each still holding its `&&`, `||`, `|` and `&`
272/// verbatim. Same scanner as [`segments_with_separators`].
273pub(super) fn sequence_statements(command: &str) -> Vec<&str> {
274 let base = command.as_ptr() as usize;
275 let mut out = Vec::new();
276 let mut start: Option<usize> = None;
277 for (segment, sep) in split_with_separators(command) {
278 let at = segment.as_ptr() as usize - base;
279 let begin = *start.get_or_insert(at);
280 if matches!(sep, Some(Separator::Sequence) | None) {
281 out.push(&command[begin..at + segment.len()]);
282 start = None;
283 }
284 }
285 if let Some(begin) = start {
286 out.push(&command[begin..]);
287 }
288 out.into_iter()
289 .map(str::trim)
290 .filter(|statement| !statement.is_empty())
291 .collect()
292}
293
294fn split_with_separators(command: &str) -> Vec<(&str, Option<Separator>)> {
295 let mut segments = Vec::new();
296 let mut start = 0;
297 let bytes = command.as_bytes();
298 let len = bytes.len();
299 let mut i = 0;
300 // Stack, not booleans: see [`Frame`]. Index 0 is always the command line.
301 let mut stack: Vec<Frame> = vec![Frame::new(FrameKind::Base)];
302
303 while i < len {
304 let ch = bytes[i];
305 // `$(` and a backtick open a substitution wherever the shell would
306 // expand one: unquoted, or inside double quotes. Single quotes inhibit
307 // it entirely, which the single-quote arm below handles by consuming
308 // everything to the closing quote.
309 let opens_substitution = |i: usize| -> Option<(FrameKind, usize)> {
310 match bytes[i] {
311 b'$' if i + 1 < len && bytes[i + 1] == b'(' => Some((FrameKind::DollarParen, 2)),
312 b'`' => Some((FrameKind::Backtick, 1)),
313 _ => None,
314 }
315 };
316
317 let frame = *stack.last().expect("base frame is never popped");
318
319 if frame.in_single_quote {
320 if ch == b'\'' {
321 stack.last_mut().expect("frame").in_single_quote = false;
322 }
323 i += 1;
324 continue;
325 }
326
327 if frame.in_double_quote {
328 match ch {
329 // \" stays inside the string; \\ consumes both so `"x\\"` closes.
330 b'\\' => i = (i + 2).min(len),
331 b'"' => {
332 stack.last_mut().expect("frame").in_double_quote = false;
333 i += 1;
334 }
335 _ => {
336 if let Some((kind, width)) = opens_substitution(i) {
337 stack.push(Frame::new(kind));
338 i += width;
339 } else {
340 i += 1;
341 }
342 }
343 }
344 continue;
345 }
346
347 // Unquoted within the current frame.
348 if let Some((kind, width)) = opens_substitution(i) {
349 if kind == FrameKind::Backtick && frame.kind == FrameKind::Backtick {
350 // The same character closes a backtick substitution.
351 stack.pop();
352 } else {
353 stack.push(Frame::new(kind));
354 }
355 i += width;
356 continue;
357 }
358
359 let at_top = stack.len() == 1;
360 let unshielded = at_top && !frame.shields_operators();
361
362 // #1793: enter/leave a `[[ … ]]` conditional so its internal `&&`/`||`
363 // stay part of the condition. Both delimiters must be standalone words
364 // (see the helpers), so a glob class (`ls a[[:alpha:]]`) or an array
365 // subscript never opens a depth that would swallow the rest of the line.
366 if ch == b'[' && opens_double_bracket(bytes, i) {
367 stack.last_mut().expect("frame").bracket_depth += 1;
368 i += 2;
369 continue;
370 }
371 if ch == b']' && frame.bracket_depth > 0 && closes_double_bracket(bytes, i) {
372 let top = stack.last_mut().expect("frame");
373 top.bracket_depth = top.bracket_depth.saturating_sub(1);
374 i += 2;
375 continue;
376 }
377
378 match ch {
379 b'\\' => {
380 // Escaped char is data (bash semantics outside quotes) — never
381 // an operator or quote opener.
382 i = (i + 2).min(len);
383 }
384 b'\'' => {
385 stack.last_mut().expect("frame").in_single_quote = true;
386 i += 1;
387 }
388 b'"' => {
389 stack.last_mut().expect("frame").in_double_quote = true;
390 i += 1;
391 }
392 b'(' => {
393 stack.last_mut().expect("frame").paren_depth += 1;
394 i += 1;
395 }
396 b')' => {
397 let top = stack.last_mut().expect("frame");
398 if top.kind == FrameKind::DollarParen && top.paren_depth == 0 {
399 // Matching close of `$(` — back to the enclosing context,
400 // whose quoting resumes exactly where it left off.
401 stack.pop();
402 } else {
403 top.paren_depth = top.paren_depth.saturating_sub(1);
404 }
405 i += 1;
406 }
407 b'{' => {
408 stack.last_mut().expect("frame").brace_depth += 1;
409 i += 1;
410 }
411 b'}' => {
412 let top = stack.last_mut().expect("frame");
413 top.brace_depth = top.brace_depth.saturating_sub(1);
414 i += 1;
415 }
416 // GH #2131: bash allows line breaks between a function head and
417 // its body (`run()\n{\n…\n}`). The newline ends no command there,
418 // so the definition stays one segment, as on a single line.
419 b'\n' | b'\r' if unshielded && is_function_head(&command[start..i]) => {
420 i += 1;
421 }
422 b'\n' | b'\r' | b';' if unshielded => {
423 segments.push((&command[start..i], Some(Separator::Sequence)));
424 i += 1;
425 start = i;
426 }
427 b'&' if unshielded => {
428 if i + 1 < len && bytes[i + 1] == b'&' {
429 // &&
430 segments.push((&command[start..i], Some(Separator::And)));
431 i += 2;
432 start = i;
433 } else if (i > 0 && matches!(bytes[i - 1], b'>' | b'<'))
434 || (i + 1 < len && bytes[i + 1] == b'>')
435 {
436 // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file`, `<&3`,
437 // `0<&-` (prev is '>' or '<') or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
438 // command — splitting here would mistake the fd/target (e.g. `1`) for a
439 // standalone command and falsely block it (#334).
440 i += 1;
441 } else {
442 // single & (background operator) — still a command separator
443 segments.push((&command[start..i], Some(Separator::Background)));
444 i += 1;
445 start = i;
446 }
447 }
448 b'|' if unshielded => {
449 if i + 1 < len && bytes[i + 1] == b'|' {
450 // ||
451 segments.push((&command[start..i], Some(Separator::Or)));
452 i += 2;
453 start = i;
454 } else if i > 0 && bytes[i - 1] == b'>' {
455 // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
456 // the redirect operator and the following token is a file
457 // path, not a command. Splitting here treated the target
458 // (e.g. `out` in `date >| out`) as a command and falsely
459 // blocked it against the allowlist (#387).
460 i += 1;
461 } else {
462 // pipe
463 segments.push((&command[start..i], Some(Separator::Pipe)));
464 i += 1;
465 start = i;
466 }
467 }
468 _ => {
469 i += 1;
470 }
471 }
472 }
473
474 if start < len {
475 segments.push((&command[start..], None));
476 }
477
478 segments
479}
480
481/// Extract normalized command tokens from a single segment (no operators):
482/// skip env assignments, skip a PowerShell assignment, return empty for a
483/// PowerShell value expression, skip a leading `{` brace-group token (#939 —
484/// lean-ctx's own agent wrapper emits this form), and take the basename of
485/// the first token. Shared by `extract_base_from_segment` (which returns just
486/// the first token) and the subcommand-scoped allowlist matcher (which needs
487/// the rest of the tokens too), so the two normalizations cannot drift
488/// (GH #1419 follow-up).
489pub(super) fn extract_command_tokens_from_segment(segment: &str) -> Vec<String> {
490 let trimmed = segment.trim();
491 if trimmed.is_empty() {
492 return Vec::new();
493 }
494
495 let cmd_part = skip_powershell_assignment(skip_env_assignments(trimmed));
496 if cmd_part.is_empty() {
497 return Vec::new();
498 }
499 if is_powershell_value_expression(cmd_part) {
500 return Vec::new();
501 }
502
503 let tokens = shell_tokenize(cmd_part);
504 // #939: a leading `{` brace-group token (e.g. from
505 // `agent_wrapper::rebuild`'s `{ <real command>\n} && pwd ...` wrapping)
506 // is not itself a command — skip it so the base extracted is the real
507 // command inside the group, not the brace.
508 let mut token_iter = tokens.into_iter();
509 let first_token = match token_iter.next() {
510 Some(t) if t == "{" => token_iter.next(),
511 other => other,
512 };
513 let Some(first_token) = first_token else {
514 return Vec::new();
515 };
516 let basename = first_token
517 .rsplit('/')
518 .next()
519 .unwrap_or(&first_token)
520 .to_string();
521
522 std::iter::once(basename).chain(token_iter).collect()
523}
524
525/// Extract the base command name from a single segment (no operators).
526pub(super) fn extract_base_from_segment(segment: &str) -> String {
527 extract_command_tokens_from_segment(segment)
528 .into_iter()
529 .next()
530 .unwrap_or_default()
531}
532
533/// Skip a local PowerShell assignment (`$result = Get-Content …`) so the
534/// wrapped cmdlet is validated. Environment/scoped variables contain `:` and
535/// intentionally remain unrecognized, preventing `$env:PATH = …` bypasses.
536pub(super) fn skip_powershell_assignment(segment: &str) -> &str {
537 let trimmed = segment.trim_start();
538 let Some(variable_end) = powershell_local_variable_end(trimmed) else {
539 return trimmed;
540 };
541 let after_variable = trimmed[variable_end..].trim_start();
542 let Some(after_equals) = after_variable.strip_prefix('=') else {
543 return trimmed;
544 };
545 after_equals.trim_start()
546}
547
548fn powershell_local_variable_end(segment: &str) -> Option<usize> {
549 let bytes = segment.as_bytes();
550 if bytes.first() != Some(&b'$') {
551 return None;
552 }
553 let mut end = 1;
554 let first = *bytes.get(end)?;
555 if !(first.is_ascii_alphabetic() || first == b'_') {
556 return None;
557 }
558 end += 1;
559 while bytes
560 .get(end)
561 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
562 {
563 end += 1;
564 }
565 (bytes.get(end) != Some(&b':')).then_some(end)
566}
567
568/// A bare local variable or simple index lookup is pipeline data, not a command.
569/// Method calls and scoped variables deliberately fall through to deny-by-default.
570fn is_powershell_value_expression(segment: &str) -> bool {
571 let trimmed = segment.trim();
572 let Some(variable_end) = powershell_local_variable_end(trimmed) else {
573 return false;
574 };
575 let suffix = trimmed[variable_end..].trim();
576 suffix.is_empty()
577 || (suffix.starts_with('[')
578 && suffix.ends_with(']')
579 && !suffix.contains(['(', ')', ';', '|', '&', '=']))
580}
581
582/// #1488: detect whether a segment is a shell function definition.
583/// Pattern: `NAME() {`, `function NAME {` or `function NAME() {`; GH #2131
584/// adds `NAME(){`, a body on the next line and a `( … )` subshell body.
585/// Returns the function name if it is a definition, `None` otherwise.
586pub(super) fn detect_function_def(segment: &str) -> Option<String> {
587 function_def_parts(segment).map(|(name, _)| name.to_string())
588}
589
590/// #1488: the commands of a function definition segment, between the outer
591/// `{` and `}` (or `(` and `)`) — `greet() { echo hi; echo bye; }` gives
592/// `echo hi; echo bye;`. The allowlist expands it like a top-level command
593/// line, so control flow in a body (`for … do … done`, `if … then … fi`) is not
594/// mistaken for commands (GH #2002).
595pub(super) fn function_body(segment: &str) -> Option<&str> {
596 function_def_parts(segment).map(|(_, body)| body)
597}
598
599/// Split a function definition into its name and body text.
600///
601/// The body must be one balanced `{ … }` group or `( … )` subshell that ends
602/// the segment (GH #2131). Anything after it, such as a redirect evaluated on
603/// every call, makes this `None`, so the segment falls through to base
604/// extraction and is blocked rather than half-checked.
605fn function_def_parts(segment: &str) -> Option<(&str, &str)> {
606 let (name, rest) = function_def_head(segment)?;
607 let body = rest.trim();
608 if body.starts_with('{') {
609 return Some((name, super::compound::balanced_brace_inner(body)?));
610 }
611 if body.starts_with('(') {
612 let (inner, end) = super::compound::balanced_paren_at(body, 0)?;
613 return (end == body.len()).then_some((name, inner.trim()));
614 }
615 None
616}
617
618/// GH #2131: is `text` exactly a function head with its body still to come?
619/// See [`split_with_separators`].
620fn is_function_head(text: &str) -> bool {
621 function_def_head(text).is_some_and(|(_, rest)| rest.trim().is_empty())
622}
623
624/// Parse a function head — `NAME()`, `NAME ()`, `function NAME` or
625/// `function NAME()` — and return the name and the text after the head. Bash
626/// accepts any whitespace, line breaks included, before the body, and
627/// `NAME(){` needs none. `function NAME` without `()` needs whitespace, as
628/// `function f{` names a function `f{`.
629fn function_def_head(segment: &str) -> Option<(&str, &str)> {
630 let cmd_part = skip_env_assignments(segment.trim());
631 let keyword_rest = cmd_part
632 .strip_prefix("function")
633 .filter(|rest| rest.starts_with(|c: char| c.is_whitespace()));
634 let (name, after_name) = match keyword_rest {
635 Some(rest) => split_function_name(rest.trim_start())?,
636 None => split_function_name(cmd_part)?,
637 };
638 match strip_empty_parens(after_name) {
639 Some(rest) => Some((name, rest)),
640 None if keyword_rest.is_some()
641 && (after_name.is_empty() || after_name.starts_with(|c: char| c.is_whitespace())) =>
642 {
643 Some((name, after_name))
644 }
645 None => None,
646 }
647}
648
649fn split_function_name(text: &str) -> Option<(&str, &str)> {
650 let end = text
651 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
652 .unwrap_or(text.len());
653 (end > 0).then(|| text.split_at(end))
654}
655
656/// `()` with optional blanks before and inside it; returns what follows.
657fn strip_empty_parens(text: &str) -> Option<&str> {
658 let rest = text.trim_start_matches([' ', '\t']).strip_prefix('(')?;
659 rest.trim_start_matches([' ', '\t']).strip_prefix(')')
660}
661
662/// Shell builtins that legitimately export or mutate environment variables.
663/// A segment beginning with one of these (`export PATH=…`, `readonly FOO=bar`)
664/// is not a bare inline `PATH=… cmd` hijack — skip the builtin and any
665/// following `VAR=value` tokens so export-only segments contribute no leaf
666/// command and `export PATH=… ; python3 …` resolves to `python3`.
667const ENV_SETTING_BUILTINS: &[&str] =
668 &["export", "unset", "readonly", "local", "declare", "typeset"];
669
670/// Skip leading KEY=VALUE environment variable assignments.
671/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
672/// skips the entire `FOO="bar baz"` token.
673pub(super) fn skip_env_assignments(segment: &str) -> &str {
674 let mut rest = segment;
675 loop {
676 let rest_trimmed = rest.trim_start();
677 if rest_trimmed.is_empty() {
678 return rest_trimmed;
679 }
680 let end = quote_aware_token_end(rest_trimmed);
681 if end == 0 {
682 return rest_trimmed;
683 }
684 let raw_token = &rest_trimmed[..end];
685 let unquoted: String = raw_token
686 .chars()
687 .filter(|c| *c != '"' && *c != '\'')
688 .collect();
689 let base_token = unquoted.rsplit('/').next().unwrap_or(unquoted.as_str());
690 if ENV_SETTING_BUILTINS.contains(&base_token) {
691 rest = &rest_trimmed[end..];
692 continue;
693 }
694 let is_posix_assignment = unquoted.split_once('=').is_some_and(|(name, _)| {
695 let mut bytes = name.bytes();
696 bytes
697 .next()
698 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
699 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
700 });
701 if is_posix_assignment {
702 rest = &rest_trimmed[end..];
703 } else {
704 return rest_trimmed;
705 }
706 }
707}
708/// Public accessor for extracting all command segments.
709pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
710 extract_all_commands(command)
711}
712// Legacy compat: single-segment extraction (used by other callers)
713pub fn extract_base_command(command: &str) -> String {
714 let first_seg = split_on_operators(command)
715 .into_iter()
716 .next()
717 .unwrap_or(command);
718 extract_base_from_segment(first_seg)
719}