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