1use std::borrow::Cow;
17use std::cell::RefCell;
18
19#[derive(Clone, Default)]
23pub struct PathCtx {
24 pub cwd: Option<String>,
25 pub root: Option<String>,
26 pub session_id: Option<String>,
29}
30
31thread_local! {
32 static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
33}
34
35#[must_use]
38pub fn enter(ctx: PathCtx) -> Guard {
39 Guard(CURRENT.with(|c| c.replace(ctx)))
40}
41
42pub struct Guard(PathCtx);
44
45impl Drop for Guard {
46 fn drop(&mut self) {
47 CURRENT.with(|c| *c.borrow_mut() = std::mem::take(&mut self.0));
48 }
49}
50
51#[must_use]
54pub fn enter_cwd(cwd: Option<String>) -> Guard {
55 Guard(CURRENT.with(|c| {
56 let mut b = c.borrow_mut();
57 PathCtx {
60 cwd: std::mem::replace(&mut b.cwd, cwd),
61 root: b.root.clone(),
62 session_id: b.session_id.clone(),
63 }
64 }))
65}
66
67pub fn cwd() -> Option<String> {
69 CURRENT.with(|c| c.borrow().cwd.clone())
70}
71
72pub fn root() -> Option<String> {
75 CURRENT.with(|c| {
76 let b = c.borrow();
77 b.root.clone().or_else(|| b.cwd.clone())
78 })
79}
80
81pub fn in_session_scratchpad(path: &str) -> bool {
102 let Some(id) = CURRENT.with(|c| c.borrow().session_id.clone()) else {
103 return false;
104 };
105 if id.len() < 8 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
108 return false;
109 }
110 if !under_temp_root(path) {
111 return false;
112 }
113 path.split('/').any(|seg| seg == id)
114}
115
116pub fn under_temp_root(path: &str) -> bool {
119 const ROOTS: &[&str] = &["/tmp/", "/private/tmp/", "/var/tmp/", "/private/var/tmp/"];
120 if ROOTS.iter().any(|r| path.starts_with(r)) {
121 return true;
122 }
123 std::env::var("TMPDIR").ok().is_some_and(|t| {
124 let t = t.trim_end_matches('/');
125 !t.is_empty() && t.starts_with('/') && path.starts_with(&format!("{t}/"))
126 })
127}
128
129struct LoopVar {
133 name: String,
134 read_repr: String,
135 write_repr: String,
136}
137
138thread_local! {
139 static LOOP_VARS: RefCell<Vec<LoopVar>> = const { RefCell::new(Vec::new()) };
140}
141
142struct VarBinding {
145 name: String,
146 value: String,
147}
148
149thread_local! {
150 static VARS: RefCell<Vec<VarBinding>> = const { RefCell::new(Vec::new()) };
151}
152
153#[must_use]
159pub fn enter_var(name: String, value: String) -> VarGuard {
160 VARS.with(|v| v.borrow_mut().push(VarBinding { name, value }));
161 VarGuard
162}
163
164pub struct VarGuard;
165
166impl Drop for VarGuard {
167 fn drop(&mut self) {
168 VARS.with(|v| {
169 v.borrow_mut().pop();
170 });
171 }
172}
173
174#[must_use]
177pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
178 LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
179 LoopGuard
180}
181
182pub struct LoopGuard;
184
185impl Drop for LoopGuard {
186 fn drop(&mut self) {
187 LOOP_VARS.with(|v| {
188 v.borrow_mut().pop();
189 });
190 }
191}
192
193thread_local! {
194 static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
195}
196
197#[must_use]
202pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
203 STDIN_REPR.with(|v| v.borrow_mut().push(repr));
204 StdinReprGuard
205}
206
207pub fn stdin_item_repr() -> Option<String> {
210 STDIN_REPR.with(|v| v.borrow().last().cloned())
211}
212
213pub struct StdinReprGuard;
214
215impl Drop for StdinReprGuard {
216 fn drop(&mut self) {
217 STDIN_REPR.with(|v| {
218 v.borrow_mut().pop();
219 });
220 }
221}
222
223pub fn expand_vars(path: &str, want_write: bool) -> Cow<'_, str> {
228 if !path.contains('$') {
229 return Cow::Borrowed(path);
230 }
231 let replaced = LOOP_VARS.with(|lv| {
232 VARS.with(|v| {
233 let loops = lv.borrow();
234 let vars = v.borrow();
235 if loops.is_empty() && vars.is_empty() {
236 None
237 } else {
238 expand_with(path, &loops, &vars, want_write)
239 }
240 })
241 });
242 replaced.map_or(Cow::Borrowed(path), Cow::Owned)
243}
244
245fn expand_with(path: &str, loops: &[LoopVar], vars: &[VarBinding], want_write: bool) -> Option<String> {
246 let mut out = String::with_capacity(path.len());
247 let mut rest = path;
248 let mut replaced = false;
249 while let Some(dollar) = rest.find('$') {
250 out.push_str(&rest[..dollar]);
251 let after = &rest[dollar + 1..];
252 match parse_var(after) {
253 Some((name, consumed)) => {
254 if let Some(lv) = loops.iter().rev().find(|v| v.name == name) {
257 out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
258 replaced = true;
259 } else if let Some(vb) = vars.iter().rev().find(|v| v.name == name) {
260 out.push_str(&vb.value);
261 replaced = true;
262 } else {
263 out.push('$');
264 out.push_str(&after[..consumed]);
265 }
266 rest = &after[consumed..];
267 }
268 None => {
269 out.push('$');
270 rest = after;
271 }
272 }
273 }
274 out.push_str(rest);
275 replaced.then_some(out)
276}
277
278fn parse_var(after: &str) -> Option<(&str, usize)> {
282 if let Some(braced) = after.strip_prefix('{') {
283 let close = braced.find('}')?;
284 let name = &braced[..close];
285 is_var_name(name).then_some((name, close + 2)) } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
287 Some((&after[..1], 1)) } else {
289 let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
290 let name = &after[..len];
291 is_var_name(name).then_some((name, len))
292 }
293}
294
295fn is_var_name(s: &str) -> bool {
298 if s.is_empty() {
299 return false;
300 }
301 if s.bytes().all(|b| b.is_ascii_digit()) {
302 return true;
303 }
304 let mut bytes = s.bytes();
305 matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_')
306 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
307}
308
309pub fn resolve(path: &str) -> Cow<'_, str> {
319 if path.is_empty() || path.starts_with('~') || path.contains('$') {
321 return Cow::Borrowed(path);
322 }
323 let resolved = CURRENT.with(|c| {
324 let ctx = c.borrow();
325 match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
326 (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
327 let abs = if path.starts_with('/') {
330 lexical_join("/", path)
331 } else {
332 lexical_join(cwd, path)
333 };
334 Some(express_relative_to_root(&abs, root))
335 }
336 _ => None,
337 }
338 });
339 resolved.map_or(Cow::Borrowed(path), Cow::Owned)
340}
341
342pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
347 if target.starts_with('~') || target.contains('$') {
348 return None; }
350 if target.starts_with('/') {
351 return Some(lexical_join("/", target)); }
353 cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, target)) }
355
356fn express_relative_to_root(abs: &str, root: &str) -> String {
361 let root = root.trim_end_matches('/');
362 if abs == root {
363 return ".".to_string(); }
365 match abs.strip_prefix(root) {
366 Some(inside) if inside.starts_with('/') => inside.trim_start_matches('/').to_string(),
367 _ => abs.to_string(),
368 }
369}
370
371fn lexical_join(base: &str, rel: &str) -> String {
374 let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
375 for seg in rel.split('/') {
376 match seg {
377 "" | "." => {}
378 ".." => {
379 parts.pop();
380 }
381 s => parts.push(s),
382 }
383 }
384 format!("/{}", parts.join("/"))
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 const SID: &str = "7676dbc5-a265-43b3-a0f8-49666792bd9b";
392
393 fn with_session<T>(id: Option<&str>, f: impl FnOnce() -> T) -> T {
394 let _g = enter(PathCtx {
395 cwd: Some("/home/u/proj".into()),
396 root: Some("/home/u/proj".into()),
397 session_id: id.map(str::to_string),
398 });
399 f()
400 }
401
402 #[test]
407 fn only_this_sessions_scratchpad_is_recognized() {
408 let scratch = format!("/private/tmp/claude-501/-Users-u-proj/{SID}/scratchpad");
409 let matching: &[String] = &[
410 format!("{scratch}/build.sh"),
411 format!("{scratch}/nested/deep/gen.py"),
412 scratch.clone(),
413 format!("/tmp/{SID}/x.sh"),
415 format!("/tmp/some-other-harness/{SID}/work/x.sh"),
416 format!("/var/tmp/{SID}/x.sh"),
417 ];
418 let rejected: &[String] = &[
419 "/private/tmp/claude-501/-Users-u-proj/00000000-1111-2222-3333-444444444444/scratchpad/x.sh".into(),
421 format!("/tmp/{SID}-evil/x.sh"),
423 format!("/tmp/evil-{SID}/x.sh"),
424 format!("/tmp/a{SID}/x.sh"),
425 format!("/home/u/{SID}/x.sh"),
427 format!("~/.ssh/{SID}/id_rsa"),
428 format!("/etc/{SID}/passwd"),
429 "/tmp/evil.sh".into(),
431 "/private/tmp/downloaded.sh".into(),
432 ];
433 with_session(Some(SID), || {
434 for p in matching {
435 assert!(in_session_scratchpad(p), "should be recognized: {p}");
436 }
437 for p in rejected {
438 assert!(!in_session_scratchpad(p), "must NOT be recognized: {p}");
439 }
440 });
441 }
442
443 #[test]
446 fn a_missing_or_unusable_session_id_recognizes_nothing() {
447 let path = format!("/tmp/{SID}/x.sh");
448 with_session(None, || {
449 assert!(!in_session_scratchpad(&path), "no session id → no recognition");
450 });
451 for weak in ["", "abc", "1234567", "..", "/", "a/b", "id with space", "x*y"] {
452 with_session(Some(weak), || {
453 assert!(
454 !in_session_scratchpad(&format!("/tmp/{weak}/x.sh")),
455 "weak id {weak:?} must not anchor recognition",
456 );
457 });
458 }
459 }
460
461 #[test]
462 fn no_context_leaves_paths_unchanged() {
463 assert_eq!(resolve("./x"), "./x");
464 assert_eq!(resolve("config"), "config");
465 assert_eq!(resolve("/etc/x"), "/etc/x");
466 }
467
468 #[test]
469 fn relative_inside_the_project_stays_worktree_relative() {
470 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
471 assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
472 assert_eq!(resolve("./y"), "sub/y");
473 assert_eq!(resolve("../z"), "z", ".. that stays inside root");
474 }
475
476 #[test]
477 fn relative_outside_the_project_becomes_absolute() {
478 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
479 assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
480 assert_eq!(resolve("passwd"), "/etc/passwd");
481 assert_eq!(resolve("*"), "/etc/*");
482 }
483
484 #[test]
485 fn dotdot_escaping_the_project_becomes_absolute() {
486 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), ..Default::default() });
487 assert_eq!(resolve("../../../etc/x"), "/etc/x");
488 }
489
490 #[test]
491 fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
492 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
493 assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
495 assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
496 assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
497 assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
498 assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
500 assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
501 assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
502 assert_eq!(
503 resolve("/home/u/proj-evil/secret"), "/home/u/proj-evil/secret",
504 "a sibling dir is not confused for inside by bare string prefix",
505 );
506 assert_eq!(resolve("$HOME/x"), "$HOME/x");
508 assert_eq!(resolve("~/x"), "~/x");
509 }
510
511 #[test]
512 fn loop_var_expands_to_its_representative_per_face() {
513 let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
514 assert_eq!(expand_vars("$f", false), "read_item");
515 assert_eq!(expand_vars("$f", true), "write_item");
516 assert_eq!(expand_vars("${f}", false), "read_item");
517 assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
518 assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
519 assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
520 assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
521 assert_eq!(expand_vars("plain", false), "plain");
522 }
523
524 #[test]
525 fn loop_var_binding_is_scoped_and_nests() {
526 assert_eq!(expand_vars("$f", false), "$f", "no binding");
527 {
528 let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
529 {
530 let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
531 assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
532 }
533 assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
534 }
535 assert_eq!(expand_vars("$f", false), "$f", "all popped");
536 }
537
538 #[test]
539 fn the_guard_restores_on_drop() {
540 {
541 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()), ..Default::default() });
542 assert_eq!(resolve("x"), "/etc/x");
543 }
544 assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
545 }
546}