1use std::borrow::Cow;
17use std::cell::RefCell;
18
19pub mod anchor;
20pub mod folder;
21pub(crate) use folder::judging;
22mod home;
23pub mod item_shape;
24mod lexical;
25use home::home_path_inside_root;
26pub use item_shape::{Binding, Facts, ItemShape, ItemShapeGuard, binding, enter_loop_shape, enter_stdin_shape, loop_shape, stdin_shape};
27use lexical::{expand_home, express_relative_to_root, lexical_join};
28
29#[derive(Clone, Default)]
33pub struct PathCtx {
34 pub cwd: Option<String>,
35 pub root: Option<String>,
36 pub session_id: Option<String>,
39}
40
41thread_local! {
42 static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
43}
44
45#[must_use]
48pub fn enter(ctx: PathCtx) -> Guard {
49 Guard(CURRENT.with(|c| c.replace(ctx)))
50}
51
52pub struct Guard(PathCtx);
54
55impl Drop for Guard {
56 fn drop(&mut self) {
57 CURRENT.with(|c| *c.borrow_mut() = std::mem::take(&mut self.0));
58 }
59}
60
61#[must_use]
64pub fn enter_cwd(cwd: Option<String>) -> Guard {
65 Guard(CURRENT.with(|c| {
66 let mut b = c.borrow_mut();
67 PathCtx { cwd: std::mem::replace(&mut b.cwd, cwd), root: b.root.clone(), session_id: b.session_id.clone() }
70 }))
71}
72
73pub fn cwd() -> Option<String> {
75 CURRENT.with(|c| c.borrow().cwd.clone())
76}
77
78pub fn root() -> Option<String> {
81 CURRENT.with(|c| {
82 let b = c.borrow();
83 b.root.clone().or_else(|| b.cwd.clone())
84 })
85}
86
87pub fn in_session_scratchpad(path: &str) -> bool {
108 let Some(id) = CURRENT.with(|c| c.borrow().session_id.clone()) else {
109 return false;
110 };
111 if id.len() < 8 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
114 return false;
115 }
116 if !under_temp_root(path) {
117 return false;
118 }
119 path.split('/').any(|seg| seg == id)
120}
121
122pub fn under_temp_root(path: &str) -> bool {
125 const ROOTS: &[&str] = &["/tmp/", "/private/tmp/", "/var/tmp/", "/private/var/tmp/"];
126 if ROOTS.iter().any(|r| path.starts_with(r)) {
127 return true;
128 }
129 std::env::var("TMPDIR").ok().is_some_and(|t| {
130 let t = t.trim_end_matches('/');
131 !t.is_empty() && t.starts_with('/') && path.starts_with(&format!("{t}/"))
132 })
133}
134
135struct LoopVar {
139 name: String,
140 read_repr: String,
141 write_repr: String,
142}
143
144thread_local! {
145 static LOOP_VARS: RefCell<Vec<LoopVar>> = const { RefCell::new(Vec::new()) };
146}
147
148struct VarBinding {
151 name: String,
152 value: String,
153}
154
155thread_local! {
156 static VARS: RefCell<Vec<VarBinding>> = const { RefCell::new(Vec::new()) };
157}
158
159#[must_use]
165pub fn enter_var(name: String, value: String) -> VarGuard {
166 VARS.with(|v| v.borrow_mut().push(VarBinding { name, value }));
167 VarGuard
168}
169
170pub struct VarGuard;
171
172impl Drop for VarGuard {
173 fn drop(&mut self) {
174 VARS.with(|v| {
175 v.borrow_mut().pop();
176 });
177 }
178}
179
180#[must_use]
183pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
184 LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
185 LoopGuard
186}
187
188pub struct LoopGuard;
190
191impl Drop for LoopGuard {
192 fn drop(&mut self) {
193 LOOP_VARS.with(|v| {
194 v.borrow_mut().pop();
195 });
196 }
197}
198
199thread_local! {
200 static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
201}
202
203#[must_use]
208pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
209 STDIN_REPR.with(|v| v.borrow_mut().push(repr));
210 StdinReprGuard
211}
212
213pub fn stdin_item_repr() -> Option<String> {
216 STDIN_REPR.with(|v| v.borrow().last().cloned())
217}
218
219pub struct StdinReprGuard;
220
221impl Drop for StdinReprGuard {
222 fn drop(&mut self) {
223 STDIN_REPR.with(|v| {
224 v.borrow_mut().pop();
225 });
226 }
227}
228
229pub fn expand_vars(path: &str, want_write: bool) -> Cow<'_, str> {
234 if !path.contains('$') {
235 return Cow::Borrowed(path);
236 }
237 let replaced = LOOP_VARS.with(|lv| {
238 VARS.with(|v| {
239 let loops = lv.borrow();
240 let vars = v.borrow();
241 if loops.is_empty() && vars.is_empty() { None } else { expand_with(path, &loops, &vars, want_write) }
242 })
243 });
244 replaced.map_or(Cow::Borrowed(path), Cow::Owned)
245}
246
247fn expand_with(path: &str, loops: &[LoopVar], vars: &[VarBinding], want_write: bool) -> Option<String> {
248 let mut out = String::with_capacity(path.len());
249 let mut rest = path;
250 let mut replaced = false;
251 while let Some(dollar) = rest.find('$') {
252 out.push_str(&rest[..dollar]);
253 let after = &rest[dollar + 1..];
254 match parse_var(after) {
255 Some((name, consumed)) => {
256 if let Some(lv) = loops.iter().rev().find(|v| v.name == name) {
259 out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
260 replaced = true;
261 } else if let Some(vb) = vars.iter().rev().find(|v| v.name == name) {
262 out.push_str(&vb.value);
263 replaced = true;
264 } else {
265 out.push('$');
266 out.push_str(&after[..consumed]);
267 }
268 rest = &after[consumed..];
269 }
270 None => {
271 out.push('$');
272 rest = after;
273 }
274 }
275 }
276 out.push_str(rest);
277 replaced.then_some(out)
278}
279
280fn parse_var(after: &str) -> Option<(&str, usize)> {
284 if let Some(braced) = after.strip_prefix('{') {
285 let close = braced.find('}')?;
286 let name = &braced[..close];
287 is_var_name(name).then_some((name, close + 2)) } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
289 Some((&after[..1], 1)) } else {
291 let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
292 let name = &after[..len];
293 is_var_name(name).then_some((name, len))
294 }
295}
296
297fn is_var_name(s: &str) -> bool {
300 if s.is_empty() {
301 return false;
302 }
303 if s.bytes().all(|b| b.is_ascii_digit()) {
304 return true;
305 }
306 let mut bytes = s.bytes();
307 matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_') && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
308}
309
310pub fn resolve(path: &str) -> Cow<'_, str> {
322 resolve_placed(path, None)
323}
324
325pub fn resolve_for(path: &str, use_: anchor::Use) -> Cow<'_, str> {
328 if use_.mutates() {
329 folder::note_named_write();
330 }
331 resolve_placed(path, Some(use_))
332}
333
334fn resolve_placed(path: &str, use_: Option<anchor::Use>) -> Cow<'_, str> {
335 if path.is_empty() || path.contains('$') {
336 return Cow::Borrowed(path);
337 }
338 if path.starts_with('~') {
339 return home_path_inside_root(path).map_or(Cow::Borrowed(path), Cow::Owned);
340 }
341 let resolved = CURRENT.with(|c| {
342 let ctx = c.borrow();
343 match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
344 (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
345 let abs = if path.starts_with('/') {
348 lexical_join("/", path)
349 } else {
350 folder::place(cwd, path, use_).map_or_else(|| lexical_join(cwd, path), |placed| lexical_join(root, &placed))
351 };
352 Some(express_relative_to_root(&abs, root))
353 }
354 _ => None,
355 }
356 });
357 resolved.map_or(Cow::Borrowed(path), Cow::Owned)
358}
359
360pub(crate) const UNRESOLVED_CWD: &str = "/__SAFE_CHAINS_CMDSUB__";
363
364pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
379 let expanded = match expand_home(target) {
380 Some(t) => t,
381 None => return Some(UNRESOLVED_CWD.to_string()), };
383 if expanded.starts_with('~') || expanded.contains('$') || crate::cst::check::is_opaque_value(&expanded) {
391 return Some(UNRESOLVED_CWD.to_string());
392 }
393 if expanded.starts_with('/') {
394 return Some(lexical_join("/", &expanded)); }
396 cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, &expanded))
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 const SID: &str = "7676dbc5-a265-43b3-a0f8-49666792bd9b";
407
408 fn with_session<T>(id: Option<&str>, f: impl FnOnce() -> T) -> T {
409 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), session_id: id.map(str::to_string) });
410 f()
411 }
412
413 #[test]
418 fn only_this_sessions_scratchpad_is_recognized() {
419 let scratch = format!("/private/tmp/claude-501/-Users-u-proj/{SID}/scratchpad");
420 let matching: &[String] = &[
421 format!("{scratch}/build.sh"),
422 format!("{scratch}/nested/deep/gen.py"),
423 scratch.clone(),
424 format!("/tmp/{SID}/x.sh"),
426 format!("/tmp/some-other-harness/{SID}/work/x.sh"),
427 format!("/var/tmp/{SID}/x.sh"),
428 ];
429 let rejected: &[String] = &[
430 "/private/tmp/claude-501/-Users-u-proj/00000000-1111-2222-3333-444444444444/scratchpad/x.sh".into(),
432 format!("/tmp/{SID}-evil/x.sh"),
434 format!("/tmp/evil-{SID}/x.sh"),
435 format!("/tmp/a{SID}/x.sh"),
436 format!("/home/u/{SID}/x.sh"),
438 format!("~/.ssh/{SID}/id_rsa"),
439 format!("/etc/{SID}/passwd"),
440 "/tmp/evil.sh".into(),
442 "/private/tmp/downloaded.sh".into(),
443 ];
444 with_session(Some(SID), || {
445 for p in matching {
446 assert!(in_session_scratchpad(p), "should be recognized: {p}");
447 }
448 for p in rejected {
449 assert!(!in_session_scratchpad(p), "must NOT be recognized: {p}");
450 }
451 });
452 }
453
454 #[test]
457 fn a_missing_or_unusable_session_id_recognizes_nothing() {
458 let path = format!("/tmp/{SID}/x.sh");
459 with_session(None, || {
460 assert!(!in_session_scratchpad(&path), "no session id → no recognition");
461 });
462 for weak in ["", "abc", "1234567", "..", "/", "a/b", "id with space", "x*y"] {
463 with_session(Some(weak), || {
464 assert!(!in_session_scratchpad(&format!("/tmp/{weak}/x.sh")), "weak id {weak:?} must not anchor recognition",);
465 });
466 }
467 }
468
469 #[test]
470 fn no_context_leaves_paths_unchanged() {
471 assert_eq!(resolve("./x"), "./x");
472 assert_eq!(resolve("config"), "config");
473 assert_eq!(resolve("/etc/x"), "/etc/x");
474 }
475
476 #[test]
477 fn relative_inside_the_project_stays_worktree_relative() {
478 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
479 assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
480 assert_eq!(resolve("./y"), "sub/y");
481 assert_eq!(resolve("../z"), "z", ".. that stays inside root");
482 }
483
484 #[test]
485 fn relative_outside_the_project_becomes_absolute() {
486 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
487 assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
488 assert_eq!(resolve("passwd"), "/etc/passwd");
489 assert_eq!(resolve("*"), "/etc/*");
490 }
491
492 #[test]
493 fn dotdot_escaping_the_project_becomes_absolute() {
494 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), ..Default::default() });
495 assert_eq!(resolve("../../../etc/x"), "/etc/x");
496 }
497
498 #[test]
499 fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
500 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
501 assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
503 assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
504 assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
505 assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
506 assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
508 assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
509 assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
510 assert_eq!(
511 resolve("/home/u/proj-evil/secret"),
512 "/home/u/proj-evil/secret",
513 "a sibling dir is not confused for inside by bare string prefix",
514 );
515 assert_eq!(resolve("$HOME/x"), "$HOME/x");
517 assert_eq!(resolve("~/x"), "~/x");
518 }
519
520 #[test]
521 fn a_home_spelled_path_inside_root_becomes_root_relative() {
522 let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
523 return;
524 };
525 let root = format!("{home}/projects/app");
526 let _g = enter(PathCtx { cwd: Some(format!("{root}/sub")), root: Some(root), ..Default::default() });
527 assert_eq!(resolve("~/projects/app/src/main.rs"), "src/main.rs");
528 assert_eq!(resolve("~/projects/app"), ".");
529 assert_eq!(resolve("~/projects/app/"), ".");
530 let under = |rest: &str| format!("~/projects/{rest}");
531 assert_eq!(resolve(&under("app/a/../b")), "b");
532 assert_eq!(resolve(&under("app/.git/hooks/pre-commit")), ".git/hooks/pre-commit");
533 for (outside, why) in [("peer/x", "a sibling"), ("app-evil/x", "no bare string prefix"), ("app/../../.ssh/id_rsa", "an escape")] {
534 assert_eq!(resolve(&under(outside)), under(outside), "{why} stays home-spelled");
535 }
536 assert_eq!(resolve("~/.ssh/id_rsa"), "~/.ssh/id_rsa");
537 assert_eq!(resolve("~"), "~");
538 assert_eq!(resolve("~bob/projects/app/x"), "~bob/projects/app/x", "another user's home is not ours");
539 }
540
541 #[test]
542 fn a_home_spelled_path_needs_an_absolute_root() {
543 let _none = enter(PathCtx { cwd: Some("/w".into()), root: None, ..Default::default() });
544 assert_eq!(resolve("~/x"), "~/x");
545 drop(_none);
546 let _rel = enter(PathCtx { cwd: Some("/w".into()), root: Some("w".into()), ..Default::default() });
547 assert_eq!(resolve("~/x"), "~/x");
548 }
549
550 #[test]
551 fn a_home_rooted_workspace_leaves_home_paths_to_the_home_classifiers() {
552 let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
553 return;
554 };
555 let _g = enter(PathCtx { cwd: Some(home.clone()), root: Some(home.clone()), ..Default::default() });
556 assert_eq!(resolve("~"), "~");
557 assert_eq!(resolve("~/notes.txt"), "~/notes.txt");
558 assert_eq!(resolve("notes.txt"), format!("{home}/notes.txt"));
559 assert_eq!(resolve("."), home);
560 }
561
562 #[test]
563 fn a_home_spelled_path_is_not_resolved_from_outside_the_root_or_through_a_glob() {
564 let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
565 return;
566 };
567 let root = format!("{home}/projects/app");
568 let outside = enter(PathCtx { cwd: Some("/etc".into()), root: Some(root.clone()), ..Default::default() });
569 assert_eq!(resolve("~/projects/app/x"), "~/projects/app/x", "a quoted `~` would name a directory under the cwd");
570 drop(outside);
571 let _g = enter(PathCtx { cwd: Some(root.clone()), root: Some(root), ..Default::default() });
572 for glob in ["app/.ss?/id_rsa", "app/*"].map(|rest| format!("~/projects/{rest}")) {
573 assert_eq!(resolve(&glob), glob);
574 }
575 }
576
577 #[test]
578 fn a_home_spelled_path_is_not_resolved_into_a_protected_root() {
579 let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
580 return;
581 };
582 let root = format!("{home}/.ssh");
583 let _g = enter(PathCtx { cwd: Some(root.clone()), root: Some(root), ..Default::default() });
584 assert_eq!(resolve("~/.ssh/id_rsa"), "~/.ssh/id_rsa");
585 }
586
587 #[test]
588 fn a_root_holding_a_protected_place_keeps_every_path_absolute() {
589 let _g = enter(PathCtx { cwd: Some("/".into()), root: Some("/".into()), ..Default::default() });
590 assert_eq!(resolve("/etc/shadow"), "/etc/shadow");
591 assert_eq!(resolve("etc/sudoers"), "/etc/sudoers");
592 assert_eq!(resolve("etc"), "/etc");
593 assert_eq!(resolve("/srv/app/x"), "/srv/app/x");
594 assert_eq!(resolve("."), "/");
595 drop(_g);
596 let _etc = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/etc".into()), ..Default::default() });
597 assert_eq!(resolve("hosts"), "hosts", "an unprotected file in a root below every home is the worktree");
598 assert_eq!(resolve("sudoers"), "/etc/sudoers");
599 assert_eq!(resolve("."), "/etc", "the root itself is above a protected place");
600 drop(_etc);
601 let _inside = enter(PathCtx { cwd: Some("/root/app".into()), root: Some("/root/app".into()), ..Default::default() });
602 assert_eq!(resolve("/root/app/x"), "x", "a root inside the protected place is where the user works");
603 }
604
605 #[test]
606 fn loop_var_expands_to_its_representative_per_face() {
607 let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
608 assert_eq!(expand_vars("$f", false), "read_item");
609 assert_eq!(expand_vars("$f", true), "write_item");
610 assert_eq!(expand_vars("${f}", false), "read_item");
611 assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
612 assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
613 assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
614 assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
615 assert_eq!(expand_vars("plain", false), "plain");
616 }
617
618 #[test]
619 fn loop_var_binding_is_scoped_and_nests() {
620 assert_eq!(expand_vars("$f", false), "$f", "no binding");
621 {
622 let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
623 {
624 let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
625 assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
626 }
627 assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
628 }
629 assert_eq!(expand_vars("$f", false), "$f", "all popped");
630 }
631
632 #[test]
633 fn the_guard_restores_on_drop() {
634 {
635 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()), ..Default::default() });
636 assert_eq!(resolve("x"), "/etc/x");
637 }
638 assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
639 }
640}