1use std::path::{Path, PathBuf};
26
27use super::command_vars::{KnownVars, expand_leading_variable, literal_assignments_before_each};
28use super::shell_allowlist::Separator;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SegmentCwd {
33 pub segment: String,
34 pub cwd: Option<PathBuf>,
37}
38
39pub fn segments_with_cwd(command: &str, base: Option<&Path>) -> Vec<SegmentCwd> {
44 track(command, base).0
45}
46
47pub fn final_cwd(command: &str, base: Option<&Path>) -> Option<PathBuf> {
49 track(command, base).1
50}
51
52struct Pending {
57 moved: Option<PathBuf>,
58 old: Option<PathBuf>,
60 skippable: bool,
62 fallback: bool,
65}
66
67impl Pending {
68 fn either(self) -> Option<PathBuf> {
69 merge(self.moved, self.old.as_deref())
70 }
71}
72
73fn track(command: &str, base: Option<&Path>) -> (Vec<SegmentCwd>, Option<PathBuf>) {
74 let segments = super::shell_allowlist::segments_with_separators(command);
75 let vars = literal_assignments_before_each(&segments);
76 let cdpath = std::env::var_os("CDPATH").is_some_and(|v| !v.is_empty())
78 || segments.iter().any(|(s, _)| s.contains("CDPATH"));
79
80 let mut cwd: Option<PathBuf> = base.map(Path::to_path_buf);
82 let mut list_base = cwd.clone();
85 let mut pending: Option<Pending> = None;
86 let mut prev: Option<Separator> = None;
87 let mut out = Vec::with_capacity(segments.len());
88
89 for ((segment, sep), vars) in segments.into_iter().zip(vars) {
90 let here = cwd.clone();
93 let mut own = here.clone();
94
95 if let Some(p) = pending.take_if(|p| p.fallback) {
96 cwd = if exits(&segment) && !p.skippable {
100 p.moved
101 } else if cd_target(&segment).is_some() || moves_opaquely(&segment) {
102 None
103 } else {
104 p.either()
105 };
106 } else if let Some(target) = cd_target(&segment) {
107 let moved = if cdpath && is_cdpath_candidate(target) {
108 None
109 } else {
110 resolve_cd(here.as_deref(), target, &vars)
111 };
112 let old = match pending.take() {
115 Some(p) => merge(here.clone(), p.old.as_deref()),
116 None => here.clone(),
117 };
118 let runs_unconditionally = matches!(
119 prev,
120 None | Some(Separator::Sequence | Separator::Background)
121 );
122 if prev == Some(Separator::Pipe) || sep == Some(Separator::Pipe) {
123 cwd = merge(moved, old.as_deref());
126 } else if runs_unconditionally && moved.as_deref().is_some_and(Path::is_dir) {
127 cwd = moved;
129 } else {
130 let skippable = prev == Some(Separator::Or);
131 let p = Pending {
132 moved,
133 old,
134 skippable,
135 fallback: sep == Some(Separator::Or),
136 };
137 cwd = match sep {
138 Some(Separator::Or) => p.old.clone(),
139 Some(Separator::Sequence) => merge(p.moved.clone(), p.old.as_deref()),
140 _ if skippable => merge(p.moved.clone(), p.old.as_deref()),
141 _ => p.moved.clone(),
142 };
143 if matches!(sep, Some(Separator::Or | Separator::And) | None) {
144 pending = Some(p);
145 }
146 }
147 } else {
148 if moves_opaquely(&segment) {
149 own = None;
150 if !segment.starts_with('(') {
153 cwd = None;
154 pending = None;
155 }
156 }
157 if let Some(p) = pending.take() {
160 match sep {
161 Some(Separator::And | Separator::Pipe) | None => pending = Some(p),
162 Some(Separator::Or | Separator::Sequence | Separator::Background) => {
163 cwd = p.either();
164 }
165 }
166 }
167 }
168
169 out.push(SegmentCwd { segment, cwd: own });
170 match sep {
171 Some(Separator::Background) => {
172 cwd.clone_from(&list_base);
173 pending = None;
174 }
175 Some(Separator::Sequence) => list_base.clone_from(&cwd),
176 _ => {}
177 }
178 prev = sep;
179 }
180 (out, cwd)
181}
182
183fn merge(a: Option<PathBuf>, b: Option<&Path>) -> Option<PathBuf> {
185 if a.as_deref() == b { a } else { None }
186}
187
188fn exits(segment: &str) -> bool {
190 matches!(segment.split_whitespace().next(), Some("exit" | "return"))
191}
192
193fn is_cdpath_candidate(target: &str) -> bool {
195 let t = target.trim_matches(['"', '\'']);
196 !(t.starts_with('/')
197 || t.starts_with('~')
198 || t.starts_with("./")
199 || t.starts_with("../")
200 || t == "."
201 || t == ".."
202 || Path::new(t).is_absolute())
203}
204
205fn moves_opaquely(segment: &str) -> bool {
222 const MOVERS: [&str; 4] = ["cd", "pushd", "popd", "chdir"];
223 let split_words = |text: &str| -> Vec<String> {
224 text.split(|c: char| c.is_whitespace() || OPERATORS.contains(c))
225 .map(str::to_string)
226 .collect()
227 };
228 let mut words = split_words(&blank_quoted(segment));
229 words.extend(
230 super::shell_allowlist::shell_tokenize(segment)
231 .iter()
232 .flat_map(|token| token.split(|c: char| OPERATORS.contains(c)))
233 .map(str::to_string),
234 );
235 words
236 .iter()
237 .any(|w| w == "eval" || MOVERS.contains(&w.as_str()))
238}
239
240const OPERATORS: &str = "(){};&|`";
242
243fn blank_quoted(segment: &str) -> String {
247 let mut out = String::with_capacity(segment.len());
248 let mut chars = segment.chars();
249 let mut single = false;
250 let mut double = false;
251 while let Some(c) = chars.next() {
252 match c {
253 '\\' if !single => {
254 let next = chars.next();
256 if double {
257 out.push(' ');
258 if next.is_some() {
259 out.push(' ');
260 }
261 } else {
262 out.push(c);
263 out.extend(next);
264 }
265 }
266 '\'' if !double => {
267 single = !single;
268 out.push(' ');
269 }
270 '"' if !single => {
271 double = !double;
272 out.push(' ');
273 }
274 _ if single || double => out.push(' '),
275 _ => out.push(c),
276 }
277 }
278 out
279}
280
281fn cd_target(segment: &str) -> Option<&str> {
283 let mut words = segment.split_whitespace();
284 if words.next()? != "cd" {
285 return None;
286 }
287 let target = words.next()?;
288 if words.next().is_some() {
289 return None;
291 }
292 Some(target)
293}
294
295fn resolve_cd(from: Option<&Path>, target: &str, vars: &KnownVars) -> Option<PathBuf> {
305 let unquoted = target.trim_matches(['"', '\'']);
306 let expanded;
307 let unquoted = if unquoted.starts_with('$') && !target.starts_with('\'') {
308 expanded = expand_leading_variable(unquoted, vars)?;
309 expanded.as_str()
310 } else {
311 unquoted
312 };
313 if unquoted.is_empty() || unquoted.contains('$') || unquoted.contains('`') || unquoted == "-" {
314 return None;
315 }
316 if let Some(rest) = unquoted.strip_prefix('~') {
318 let home = std::env::var_os("HOME").map(PathBuf::from)?;
319 return Some(join_in(&home, rest.trim_start_matches('/')));
320 }
321 if unquoted.starts_with('/') {
328 return Some(PathBuf::from(normalize_posix(unquoted)));
329 }
330 let path = Path::new(unquoted);
331 if path.is_absolute() {
332 return Some(normalize(path));
333 }
334 Some(join_in(from?, unquoted))
335}
336
337pub fn join_in(base: &Path, rel: &str) -> PathBuf {
342 let base_str = base.to_string_lossy();
343 if base_str.starts_with('/') {
344 return PathBuf::from(normalize_posix(&format!(
345 "{}/{rel}",
346 base_str.trim_end_matches('/')
347 )));
348 }
349 normalize(&base.join(rel))
350}
351
352fn normalize_posix(path: &str) -> String {
354 let mut out: Vec<&str> = Vec::new();
355 for part in path.split('/') {
356 match part {
357 "" | "." => {}
358 ".." => {
359 out.pop();
360 }
361 other => out.push(other),
362 }
363 }
364 format!("/{}", out.join("/"))
365}
366
367fn normalize(path: &Path) -> PathBuf {
370 use std::path::Component;
371 let mut out = PathBuf::new();
372 for component in path.components() {
373 match component {
374 Component::CurDir => {}
375 Component::ParentDir => {
376 out.pop();
377 }
378 other => out.push(other),
379 }
380 }
381 out
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn a_leading_cd_moves_the_following_segments() {
390 let segs = segments_with_cwd(
391 "cd /tmp/work && curl -o x.png http://e/x",
392 Some(Path::new("/proj")),
393 );
394 assert_eq!(
395 segs.last().unwrap().cwd.as_deref(),
396 Some(Path::new("/tmp/work"))
397 );
398 }
399
400 #[test]
401 fn without_a_cd_every_segment_keeps_the_call_directory() {
402 let segs = segments_with_cwd("echo a && echo b", Some(Path::new("/proj")));
403 assert!(
404 segs.iter()
405 .all(|s| s.cwd.as_deref() == Some(Path::new("/proj")))
406 );
407 }
408
409 #[test]
410 fn a_relative_cd_is_joined_onto_the_call_directory() {
411 assert_eq!(
412 final_cwd("cd sub/dir && ls", Some(Path::new("/proj"))),
413 Some(PathBuf::from("/proj/sub/dir"))
414 );
415 assert_eq!(
416 final_cwd("cd ../side && ls", Some(Path::new("/proj/here"))),
417 Some(PathBuf::from("/proj/side"))
418 );
419 }
420
421 #[test]
425 fn an_unresolvable_cd_makes_the_directory_unknown() {
426 assert_eq!(
427 final_cwd(
428 "cd \"$LEAN_CTX_2079_NEVER_SET\" && ls",
429 Some(Path::new("/proj"))
430 ),
431 None
432 );
433 assert_eq!(final_cwd("cd - && ls", Some(Path::new("/proj"))), None);
434 }
435
436 #[test]
440 fn a_unix_path_in_shell_text_is_absolute_on_every_platform() {
441 assert_eq!(
442 final_cwd("cd /tmp && curl -o shot.png https://e/x", None),
443 Some(PathBuf::from("/tmp"))
444 );
445 assert_eq!(
446 final_cwd("cd /private/tmp/session/../session && ls", None),
447 Some(PathBuf::from("/private/tmp/session"))
448 );
449 assert_eq!(
450 join_in(Path::new("/tmp/work"), "sub/shot.png"),
451 PathBuf::from("/tmp/work/sub/shot.png"),
452 "and joins with a forward slash, which the scratch prefixes match on"
453 );
454 }
455
456 #[test]
457 fn no_base_and_no_cd_is_unknown() {
458 assert_eq!(final_cwd("ls -la", None), None);
459 }
460
461 fn cwds(command: &str) -> Vec<Option<PathBuf>> {
462 segments_with_cwd(command, Some(Path::new("/proj")))
463 .into_iter()
464 .map(|s| s.cwd)
465 .collect()
466 }
467
468 const MISSING: &str = "/lean-ctx-1850-does-not-exist";
469
470 #[test]
472 fn a_cd_segment_runs_in_the_directory_before_it() {
473 assert_eq!(cwds("cd /tmp && ls")[0], Some(PathBuf::from("/proj")));
474 }
475
476 #[test]
479 fn a_cd_that_may_fail_does_not_carry_past_a_sequence() {
480 let got = cwds(&format!("cd {MISSING} && true; echo x"));
481 assert_eq!(got[1], Some(PathBuf::from(MISSING)), "reached only via &&");
482 assert_eq!(got[2], None);
483 }
484
485 #[test]
486 fn a_cd_that_may_be_skipped_leaves_the_directory_unknown() {
487 assert_eq!(cwds("false && cd /tmp; echo x")[2], None);
488 assert_eq!(cwds("true || cd /tmp && echo x")[2], None);
489 assert_eq!(cwds("cd /tmp | cat; echo x")[2], None, "a pipeline element");
490 }
491
492 #[test]
495 fn a_second_cd_does_not_make_a_failed_first_one_certain() {
496 assert_eq!(cwds(&format!("cd {MISSING} && cd . ; echo x"))[2], None);
497 }
498
499 #[test]
500 fn cd_or_exit_leaves_only_the_moved_directory() {
501 let got = cwds(&format!("cd {MISSING} || exit 1; echo x"));
502 assert_eq!(got[1], Some(PathBuf::from("/proj")), "the fallback");
503 assert_eq!(got[2], Some(PathBuf::from(MISSING)));
504 }
505
506 #[test]
508 fn a_backgrounded_list_does_not_move_what_follows() {
509 assert_eq!(
510 cwds("cd /tmp && echo a & echo b")[2],
511 Some(PathBuf::from("/proj"))
512 );
513 }
514
515 #[test]
516 fn opaque_movers_make_the_directory_unknown() {
517 for command in [
518 "pushd /tmp && echo x",
519 "builtin cd /tmp && echo x",
520 "{ cd /tmp; } && echo x",
521 ] {
522 assert_eq!(cwds(command).last(), Some(&None), "{command}");
523 }
524 let subshell = cwds("(cd /tmp) && echo x");
525 assert_eq!(subshell[0], None, "its own directory is not followed");
526 assert_eq!(
527 subshell[1],
528 Some(PathBuf::from("/proj")),
529 "but it cannot move the parent shell"
530 );
531 }
532
533 #[test]
534 fn cdpath_makes_a_relative_cd_unknown() {
535 assert_eq!(cwds("CDPATH=/elsewhere; cd sub && ls")[2], None);
536 assert_eq!(
537 cwds("CDPATH=/elsewhere; cd ./sub && ls")[2],
538 Some(PathBuf::from("/proj/sub")),
539 "`./` bypasses CDPATH"
540 );
541 }
542
543 #[test]
546 fn a_cd_inside_a_quoted_argument_does_not_move_anything() {
547 for command in [
548 "printf '%s\\n' 'note: cd /tmp mention' > f.txt",
549 "cd /proj && printf '| %s | (cd $TMPDIR not followed) |\\n' x >> f.txt",
550 "echo \"pushd here, popd there, then cd back\" > f.txt",
551 "echo \"$(cd /tmp; pwd)\" > f.txt",
552 "echo 'it'\\''s cd' > f.txt",
553 ] {
554 assert_eq!(
555 cwds(command).last(),
556 Some(&Some(PathBuf::from("/proj"))),
557 "{command}"
558 );
559 }
560 }
561
562 #[test]
565 fn a_quoted_command_name_or_eval_still_moves_the_shell() {
566 for command in [
567 "\"cd\" /tmp && echo x",
568 "c'd' /tmp && echo x",
569 "\\cd /tmp && echo x",
570 "builtin 'cd' /tmp && echo x",
571 "eval 'cd /tmp' && echo x",
572 "'eval' \"c\"\"d /tmp\" && echo x",
573 "echo ${ cd /tmp; } && echo x",
574 ] {
575 assert_eq!(cwds(command).last(), Some(&None), "{command}");
576 }
577 }
578
579 #[test]
582 fn a_cd_through_a_known_variable_is_followed() {
583 assert_eq!(
584 cwds("D=/tmp && cd $D && echo x").last(),
585 Some(&Some(PathBuf::from("/tmp")))
586 );
587 assert_eq!(
588 cwds("D=/tmp; cd \"${D}/sub\" && echo x").last(),
589 Some(&Some(PathBuf::from("/tmp/sub")))
590 );
591 assert_eq!(
592 cwds("D=sub; cd $D && echo x").last(),
593 Some(&Some(PathBuf::from("/proj/sub"))),
594 "a relative value is joined like a relative literal"
595 );
596 let expected = std::env::var("TMPDIR")
597 .ok()
598 .filter(|t| !t.is_empty() && !t.contains(['$', '`']))
599 .map(|t| resolve_cd(None, &t, &KnownVars::default()));
600 assert_eq!(
601 final_cwd("cd $TMPDIR && ls", Some(Path::new("/proj"))),
602 expected.flatten(),
603 "$TMPDIR resolves exactly as its value spelled out"
604 );
605 }
606
607 #[test]
609 fn a_cd_through_an_unknown_variable_stays_unknown() {
610 for command in [
611 "cd $D && echo x",
612 "true && D=/tmp; cd $D && echo x",
613 "D=/tmp; read D; cd $D && echo x",
614 "TMPDIR=$PWD; cd $TMPDIR && echo x",
615 "unset TMPDIR; cd $TMPDIR && echo x",
616 "D=/tmp; cd '$D' && echo x",
618 "D=/tmp; cd $Dx && echo x",
619 ] {
620 assert_eq!(cwds(command).last(), Some(&None), "{command}");
621 }
622 }
623}