safe_chains/pathctx.rs
1//! The directory context the harness supplies (HP-19): the working directory a command
2//! runs in, and the project root. It exists to make relative-path classification honest —
3//! `cd /etc && echo > ./x` must be seen as writing `/etc/x`, not a worktree file.
4//!
5//! The context is **ambient** for one command evaluation: a single `cwd`/`root` pair threads
6//! logically through the whole recursive verdict tree (script → pipeline → cmd → redirect →
7//! leaf). Rather than add a pass-through parameter to ~15 recursive functions across `cst`,
8//! `handlers`, and `engine`, it lives in a scoped thread-local, installed by
9//! [`enter`] at the top of an evaluation and read at exactly two leaves: legacy
10//! `is_safe_write_target` and engine `classify_locus`, both via [`resolve`].
11//!
12//! Everything is fail-open to *today's* behavior: with no `cwd`/`root` (or an unresolvable
13//! path) [`resolve`] returns the path unchanged, so the classifiers behave exactly as before
14//! — the signal tightens when present, never a regression when absent.
15
16use std::borrow::Cow;
17use std::cell::RefCell;
18
19/// The working directory and project root for the command under evaluation. Both optional:
20/// a harness may supply neither (e.g. opencode), and classification falls back to the
21/// relative-is-worktree assumption.
22#[derive(Clone, Default)]
23pub struct PathCtx {
24 pub cwd: Option<String>,
25 pub root: Option<String>,
26 /// The harness's session id, when it supplies one. Only used to recognize this session's
27 /// SCRATCHPAD — see [`in_session_scratchpad`].
28 pub session_id: Option<String>,
29}
30
31thread_local! {
32 static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
33}
34
35/// Install `ctx` as the ambient context for the duration of the returned guard; the previous
36/// context is restored on drop (panic-safe, so a failing test can't leak into the next).
37#[must_use]
38pub fn enter(ctx: PathCtx) -> Guard {
39 Guard(CURRENT.with(|c| c.replace(ctx)))
40}
41
42/// Restores the previous [`PathCtx`] when dropped.
43pub 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/// Run `f` with the ambient `cwd` temporarily replaced (root unchanged) — used by intra-line
52/// `cd` tracking as it walks a chain's statements. Restored on drop.
53#[must_use]
54pub fn enter_cwd(cwd: Option<String>) -> Guard {
55 Guard(CURRENT.with(|c| {
56 let mut b = c.borrow_mut();
57 // `session_id` is carried through unchanged: a `cd` mid-chain must not drop scratchpad
58 // recognition (the session is the same session whatever directory it walks into).
59 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
67/// The ambient working directory, if known.
68pub fn cwd() -> Option<String> {
69 CURRENT.with(|c| c.borrow().cwd.clone())
70}
71
72/// The workspace ROOT (project dir), if known — falling back to `cwd` (the hook defaults root to
73/// cwd). Used by the adjacent-sibling classifier to find the workspace's parent.
74pub 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
81/// Whether `path` lies inside THIS session's scratchpad — the harness's own per-session working
82/// directory (e.g. Claude Code's `/private/tmp/claude-<uid>/<project-slug>/<session-id>/scratchpad`).
83///
84/// The anchor is the **session id as a whole path component**, not the surrounding layout. That is
85/// deliberate and is what makes this safe *and* durable:
86///
87/// - **Unforgeable.** The session id arrives in the harness's own hook envelope, never from the
88/// agent's shell. An attacker cannot pre-plant `/tmp/<this-session-id>/evil.sh` because the id is
89/// unknown until the session exists (and it is unique per session). Compare a *layout* pattern
90/// ("anything under `/tmp/claude-*`"), which anyone can create.
91/// - **Durable.** It survives the harness reorganizing the parts around the id — the uid suffix,
92/// the slug, `/tmp` vs `/private/tmp`, the trailing directory name. Those are internal details
93/// (Claude Code does not document or expose the scratchpad path, and declined to; see
94/// docs/design/agent-scratchpad.md), so matching them exactly would be brittle.
95/// - **Fail-closed.** No session id, or a path that does not contain it, simply does not match:
96/// the path keeps its ordinary classification (`/tmp` → `temp`, i.e. foreign). A harness that
97/// supplies no id, or whose scratchpad omits it, is exactly as restricted as before — never worse.
98///
99/// Requiring a TEMP-root prefix as well keeps the id from blessing something outside the scratch
100/// area on a harness that happens to embed the id elsewhere (a log path under `$HOME`, say).
101pub 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 // A short or trivial id could collide with an ordinary directory name; require something
106 // id-shaped before trusting it as an anchor.
107 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
116/// Whether `path` is under a temporary-filesystem root. macOS's `/tmp` is a symlink to
117/// `/private/tmp`, and harnesses report either spelling, so both are accepted (as is `$TMPDIR`).
118pub 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
129/// A bound `for` loop variable: `$name` in the body inherits the loop's `in`-list locus (the
130/// `find … {}`→path binding, one layer up). Read and write representatives can differ — a list
131/// like `/etc/hosts ~/notes` reads worst at `~/notes` but writes worst at `/etc/hosts`.
132struct 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
142/// A bound `VAR=value` assignment or a function positional (`$1`). Unlike a loop var, a certain
143/// literal value has ONE representative for both read and write.
144struct 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/// Bind `name` to a CERTAIN literal `value` (a `VAR=/path` assignment, or `$1` at a function call)
154/// for the duration of the guard; consulted by `expand_vars` AFTER loop vars, so the innermost/latest
155/// binding wins. The caller binds only values it is certain of — an uncertain value (`VAR=$(cmd)`,
156/// `VAR=$UNBOUND`) is bound to the unpinnable sentinel so `$VAR` still fail-closes rather than
157/// resolving to a stale or dropped value.
158#[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/// Bind loop variable `name` to its list's representative items for the duration of the guard
175/// (the loop body's classification). Nested loops stack; the innermost binding of a name wins.
176#[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
182/// Pops the loop binding when dropped.
183pub 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/// Bind the representative PATH of the items arriving on stdin, for the duration of the guard —
198/// set by the pipeline walker to the previous stage's output-path locus. An operand-injecting
199/// consumer (`xargs`) reads it so `find / | xargs cat` gates the injected operand at `/`, while
200/// `find ./src | xargs cat` gates it at the workspace (mirrors `find -exec`'s `{}` binding).
201#[must_use]
202pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
203 STDIN_REPR.with(|v| v.borrow_mut().push(repr));
204 StdinReprGuard
205}
206
207/// The current stdin-item representative, or `None` when the source is unknown (no pipe / an
208/// unmodeled producer) — in which case the consumer worst-cases the injected operand.
209pub 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
223/// Expand any bound loop variable (`$name` / `${name}`) in `path` to its representative list
224/// item — the read representative when `want_write` is false, the write representative when
225/// true. Unbound `$…` is left untouched (so it still fail-closes to machine). Returns `path`
226/// unchanged when nothing is bound.
227pub 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 // Loop bindings first (they carry read/write reprs), then assignment/positional
255 // bindings; innermost/latest wins in each. Unbound in BOTH → left untouched.
256 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
278/// Parse a shell variable name immediately after a `$`: `name`, `{name}`, a single-digit positional
279/// (`$1`; bash reads `$12` as `$1` then `2`), or a braced positional (`${10}`). Returns the name and
280/// how many bytes of `after` it consumed, or `None` if it isn't a variable reference.
281fn 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)) // '{' + name + '}'
286 } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
287 Some((&after[..1], 1)) // unbraced positional: exactly one digit
288 } 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
295/// A `${…}` interior is a variable name — an identifier (`[A-Za-z_][A-Za-z0-9_]*`) OR an all-digit
296/// positional (`${10}`).
297fn 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
309/// Resolve a path argument for classification against the ambient `cwd`/`root`. Returns a
310/// path the *existing* classifiers (`classify_locus`, `is_safe_write_target`) can score
311/// unchanged. When `cwd` and `root` are both known and absolute, a **relative** path is lexically
312/// joined onto `cwd` (no filesystem access) and an **absolute** path is normalized in place; then
313/// either way, if the result is inside `root` it comes back as a **root-relative** path (so the
314/// classifiers see "worktree"), and if it escaped `root` (e.g. `cwd` is `/etc`, or an absolute
315/// `/etc/hosts`) it comes back **absolute** (so they see `machine`/etc.). This makes the absolute
316/// and relative spellings of the SAME in-root file classify identically — safety on the OPERATION,
317/// not the SYNTAX. A `~` (home) or `$`-unpinnable path, or no context, is returned as-is.
318pub fn resolve(path: &str) -> Cow<'_, str> {
319 // Home (`~`) is handled by the classifiers directly; a `$` path can't be joined at all.
320 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 // Relative → join onto cwd; absolute → normalize in place. Then express relative
328 // to root if inside (worktree), else absolute.
329 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
342/// The cwd after a `cd` whose target cannot be pinned. It looks like a path AND is unpinnable, so
343/// every later relative path resolved against it worst-cases in both gate layers.
344pub(crate) const UNRESOLVED_CWD: &str = "/__SAFE_CHAINS_CMDSUB__";
345
346/// Resolve a `cd` target to a new working directory. `cur` is the current cwd, needed for a
347/// *relative* target. Used by intra-line `cd` tracking (HP-19 #2).
348///
349/// A target that cannot be pinned yields [`UNRESOLVED_CWD`], NOT `None`. This used to return `None`
350/// for `~…` and `$VAR`, and the caller reads `None` as "no cd happened" and keeps the previous cwd
351/// — so the `cd` was silently ignored and every later relative path was judged against a workspace
352/// the shell had already left. `cd ~/.aws && cat credentials` and `cd ~/.claude && echo … >
353/// settings.json` both auto-approved that way, the second writing the very file the allowlist
354/// bridge trusts. `None` now means only what the caller can act on: `cd_target` already returned
355/// `None` for "not a cd" (bare `cd`, `cd -`), so reaching here means the shell definitely moved.
356///
357/// `~`/`~/…` are EXPANDED rather than blanket-refused, because they are pinnable — that keeps
358/// `cd ~/.aws && cat credentials` gated as the credential read it is instead of coarsely denying
359/// everything downstream.
360pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
361 let expanded = match expand_home(target) {
362 Some(t) => t,
363 None => return Some(UNRESOLVED_CWD.to_string()), // `~` with no HOME
364 };
365 // Another user's home, a variable, an UNDECLARED substitution: the shell moved somewhere we
366 // cannot name. Fail closed rather than pretend it stayed put.
367 //
368 // A DECLARED substitution (`cd $(pwd)`) is deliberately not in that set. Its sentinel carries a
369 // locus, so letting it through the joins below keeps the cwd at that locus — `cd $(pwd) && cat
370 // f` stays a worktree read, while `cd $(fd d /etc) && cat f` is a machine one. Refusing both
371 // would have been sound but needlessly coarse.
372 if expanded.starts_with('~')
373 || expanded.contains('$')
374 || crate::cst::check::is_opaque_value(&expanded)
375 {
376 return Some(UNRESOLVED_CWD.to_string());
377 }
378 if expanded.starts_with('/') {
379 return Some(lexical_join("/", &expanded)); // absolute — normalize
380 }
381 // Relative with no known base: the base was already unknown before the `cd`, so this changes
382 // nothing about how later paths are judged. Left as "no update" rather than tightened, so the
383 // fix stays aimed at the hole (a cd to a KNOWN-elsewhere place being dropped).
384 cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, &expanded))
385}
386
387/// `~` / `~/rest` expanded against `$HOME`. `~user` is left alone (we cannot resolve another user's
388/// home); `None` only when `~` is used and `$HOME` is unusable.
389fn expand_home(target: &str) -> Option<Cow<'_, str>> {
390 let rest = match target {
391 "~" => "",
392 t => match t.strip_prefix("~/") {
393 Some(r) => r,
394 None => return Some(Cow::Borrowed(target)),
395 },
396 };
397 let home = std::env::var("HOME").ok().filter(|h| h.starts_with('/'))?;
398 Some(Cow::Owned(if rest.is_empty() { home } else { format!("{home}/{rest}") }))
399}
400
401/// Express an absolute `abs` path relative to `root`: `.` if it IS the root, a root-relative path
402/// if it's inside (classified as worktree), or the absolute path unchanged if it escaped (classified
403/// as machine/etc.). The `inside.starts_with('/')` guard prevents a sibling like `/proj-evil` from
404/// matching root `/proj` by bare string prefix.
405fn express_relative_to_root(abs: &str, root: &str) -> String {
406 let root = root.trim_end_matches('/');
407 if abs == root {
408 return ".".to_string(); // the project root itself
409 }
410 match abs.strip_prefix(root) {
411 Some(inside) if inside.starts_with('/') => inside.trim_start_matches('/').to_string(),
412 _ => abs.to_string(),
413 }
414}
415
416/// Join a relative path onto an absolute base, resolving `.` and `..` purely lexically. A
417/// `..` that would climb above `/` is clamped there.
418fn lexical_join(base: &str, rel: &str) -> String {
419 let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
420 for seg in rel.split('/') {
421 match seg {
422 "" | "." => {}
423 ".." => {
424 parts.pop();
425 }
426 s => parts.push(s),
427 }
428 }
429 format!("/{}", parts.join("/"))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 const SID: &str = "7676dbc5-a265-43b3-a0f8-49666792bd9b";
437
438 fn with_session<T>(id: Option<&str>, f: impl FnOnce() -> T) -> T {
439 let _g = enter(PathCtx {
440 cwd: Some("/home/u/proj".into()),
441 root: Some("/home/u/proj".into()),
442 session_id: id.map(str::to_string),
443 });
444 f()
445 }
446
447 /// The recognition rule is a SECURITY boundary: matching a path that is not really this
448 /// session's scratchpad would hand `sandbox-scope` (and therefore EXECUTE) to foreign code. So
449 /// enumerate the spoofing class rather than one example — every way a hostile or unrelated path
450 /// could try to look like the scratchpad must fail, and every legitimate spelling must match.
451 #[test]
452 fn only_this_sessions_scratchpad_is_recognized() {
453 let scratch = format!("/private/tmp/claude-501/-Users-u-proj/{SID}/scratchpad");
454 let matching: &[String] = &[
455 format!("{scratch}/build.sh"),
456 format!("{scratch}/nested/deep/gen.py"),
457 scratch.clone(),
458 // layout around the id varies by harness/platform — the id is the anchor, not the shape
459 format!("/tmp/{SID}/x.sh"),
460 format!("/tmp/some-other-harness/{SID}/work/x.sh"),
461 format!("/var/tmp/{SID}/x.sh"),
462 ];
463 let rejected: &[String] = &[
464 // a DIFFERENT session's scratchpad — same layout, wrong id
465 "/private/tmp/claude-501/-Users-u-proj/00000000-1111-2222-3333-444444444444/scratchpad/x.sh".into(),
466 // the id as a SUBSTRING of a component, not a component (the prefix/suffix attack)
467 format!("/tmp/{SID}-evil/x.sh"),
468 format!("/tmp/evil-{SID}/x.sh"),
469 format!("/tmp/a{SID}/x.sh"),
470 // right id, but OUTSIDE any temp root — the id must not bless arbitrary locations
471 format!("/home/u/{SID}/x.sh"),
472 format!("~/.ssh/{SID}/id_rsa"),
473 format!("/etc/{SID}/passwd"),
474 // anonymous temp files carry no id at all
475 "/tmp/evil.sh".into(),
476 "/private/tmp/downloaded.sh".into(),
477 ];
478 with_session(Some(SID), || {
479 for p in matching {
480 assert!(in_session_scratchpad(p), "should be recognized: {p}");
481 }
482 for p in rejected {
483 assert!(!in_session_scratchpad(p), "must NOT be recognized: {p}");
484 }
485 });
486 }
487
488 /// Fail closed on every degenerate session id: no harness id, or one too short/odd to be a
489 /// trustworthy anchor, must recognize NOTHING — never widen a path.
490 #[test]
491 fn a_missing_or_unusable_session_id_recognizes_nothing() {
492 let path = format!("/tmp/{SID}/x.sh");
493 with_session(None, || {
494 assert!(!in_session_scratchpad(&path), "no session id → no recognition");
495 });
496 for weak in ["", "abc", "1234567", "..", "/", "a/b", "id with space", "x*y"] {
497 with_session(Some(weak), || {
498 assert!(
499 !in_session_scratchpad(&format!("/tmp/{weak}/x.sh")),
500 "weak id {weak:?} must not anchor recognition",
501 );
502 });
503 }
504 }
505
506 #[test]
507 fn no_context_leaves_paths_unchanged() {
508 assert_eq!(resolve("./x"), "./x");
509 assert_eq!(resolve("config"), "config");
510 assert_eq!(resolve("/etc/x"), "/etc/x");
511 }
512
513 #[test]
514 fn relative_inside_the_project_stays_worktree_relative() {
515 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
516 assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
517 assert_eq!(resolve("./y"), "sub/y");
518 assert_eq!(resolve("../z"), "z", ".. that stays inside root");
519 }
520
521 #[test]
522 fn relative_outside_the_project_becomes_absolute() {
523 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
524 assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
525 assert_eq!(resolve("passwd"), "/etc/passwd");
526 assert_eq!(resolve("*"), "/etc/*");
527 }
528
529 #[test]
530 fn dotdot_escaping_the_project_becomes_absolute() {
531 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), ..Default::default() });
532 assert_eq!(resolve("../../../etc/x"), "/etc/x");
533 }
534
535 #[test]
536 fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
537 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
538 // absolute INSIDE root → root-relative (worktree), matching the relative spelling
539 assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
540 assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
541 assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
542 assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
543 // absolute OUTSIDE root → unchanged (classified as machine)
544 assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
545 assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
546 assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
547 assert_eq!(
548 resolve("/home/u/proj-evil/secret"), "/home/u/proj-evil/secret",
549 "a sibling dir is not confused for inside by bare string prefix",
550 );
551 // home / unpinnable → returned as-is (the classifiers handle these)
552 assert_eq!(resolve("$HOME/x"), "$HOME/x");
553 assert_eq!(resolve("~/x"), "~/x");
554 }
555
556 #[test]
557 fn loop_var_expands_to_its_representative_per_face() {
558 let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
559 assert_eq!(expand_vars("$f", false), "read_item");
560 assert_eq!(expand_vars("$f", true), "write_item");
561 assert_eq!(expand_vars("${f}", false), "read_item");
562 assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
563 assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
564 assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
565 assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
566 assert_eq!(expand_vars("plain", false), "plain");
567 }
568
569 #[test]
570 fn loop_var_binding_is_scoped_and_nests() {
571 assert_eq!(expand_vars("$f", false), "$f", "no binding");
572 {
573 let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
574 {
575 let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
576 assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
577 }
578 assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
579 }
580 assert_eq!(expand_vars("$f", false), "$f", "all popped");
581 }
582
583 #[test]
584 fn the_guard_restores_on_drop() {
585 {
586 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()), ..Default::default() });
587 assert_eq!(resolve("x"), "/etc/x");
588 }
589 assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
590 }
591}