Skip to main content

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}
27
28thread_local! {
29    static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
30}
31
32/// Install `ctx` as the ambient context for the duration of the returned guard; the previous
33/// context is restored on drop (panic-safe, so a failing test can't leak into the next).
34#[must_use]
35pub fn enter(ctx: PathCtx) -> Guard {
36    Guard(CURRENT.with(|c| c.replace(ctx)))
37}
38
39/// Restores the previous [`PathCtx`] when dropped.
40pub struct Guard(PathCtx);
41
42impl Drop for Guard {
43    fn drop(&mut self) {
44        CURRENT.with(|c| *c.borrow_mut() = std::mem::take(&mut self.0));
45    }
46}
47
48/// Run `f` with the ambient `cwd` temporarily replaced (root unchanged) — used by intra-line
49/// `cd` tracking as it walks a chain's statements. Restored on drop.
50#[must_use]
51pub fn enter_cwd(cwd: Option<String>) -> Guard {
52    Guard(CURRENT.with(|c| {
53        let mut b = c.borrow_mut();
54        PathCtx { cwd: std::mem::replace(&mut b.cwd, cwd), root: b.root.clone() }
55    }))
56}
57
58/// The ambient working directory, if known.
59pub fn cwd() -> Option<String> {
60    CURRENT.with(|c| c.borrow().cwd.clone())
61}
62
63/// The workspace ROOT (project dir), if known — falling back to `cwd` (the hook defaults root to
64/// cwd). Used by the adjacent-sibling classifier to find the workspace's parent.
65pub fn root() -> Option<String> {
66    CURRENT.with(|c| {
67        let b = c.borrow();
68        b.root.clone().or_else(|| b.cwd.clone())
69    })
70}
71
72/// A bound `for` loop variable: `$name` in the body inherits the loop's `in`-list locus (the
73/// `find … {}`→path binding, one layer up). Read and write representatives can differ — a list
74/// like `/etc/hosts ~/notes` reads worst at `~/notes` but writes worst at `/etc/hosts`.
75struct LoopVar {
76    name: String,
77    read_repr: String,
78    write_repr: String,
79}
80
81thread_local! {
82    static LOOP_VARS: RefCell<Vec<LoopVar>> = const { RefCell::new(Vec::new()) };
83}
84
85/// A bound `VAR=value` assignment or a function positional (`$1`). Unlike a loop var, a certain
86/// literal value has ONE representative for both read and write.
87struct VarBinding {
88    name: String,
89    value: String,
90}
91
92thread_local! {
93    static VARS: RefCell<Vec<VarBinding>> = const { RefCell::new(Vec::new()) };
94}
95
96/// Bind `name` to a CERTAIN literal `value` (a `VAR=/path` assignment, or `$1` at a function call)
97/// for the duration of the guard; consulted by `expand_vars` AFTER loop vars, so the innermost/latest
98/// binding wins. The caller binds only values it is certain of — an uncertain value (`VAR=$(cmd)`,
99/// `VAR=$UNBOUND`) is bound to the unpinnable sentinel so `$VAR` still fail-closes rather than
100/// resolving to a stale or dropped value.
101#[must_use]
102pub fn enter_var(name: String, value: String) -> VarGuard {
103    VARS.with(|v| v.borrow_mut().push(VarBinding { name, value }));
104    VarGuard
105}
106
107pub struct VarGuard;
108
109impl Drop for VarGuard {
110    fn drop(&mut self) {
111        VARS.with(|v| {
112            v.borrow_mut().pop();
113        });
114    }
115}
116
117/// Bind loop variable `name` to its list's representative items for the duration of the guard
118/// (the loop body's classification). Nested loops stack; the innermost binding of a name wins.
119#[must_use]
120pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
121    LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
122    LoopGuard
123}
124
125/// Pops the loop binding when dropped.
126pub struct LoopGuard;
127
128impl Drop for LoopGuard {
129    fn drop(&mut self) {
130        LOOP_VARS.with(|v| {
131            v.borrow_mut().pop();
132        });
133    }
134}
135
136thread_local! {
137    static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
138}
139
140/// Bind the representative PATH of the items arriving on stdin, for the duration of the guard —
141/// set by the pipeline walker to the previous stage's output-path locus. An operand-injecting
142/// consumer (`xargs`) reads it so `find / | xargs cat` gates the injected operand at `/`, while
143/// `find ./src | xargs cat` gates it at the workspace (mirrors `find -exec`'s `{}` binding).
144#[must_use]
145pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
146    STDIN_REPR.with(|v| v.borrow_mut().push(repr));
147    StdinReprGuard
148}
149
150/// The current stdin-item representative, or `None` when the source is unknown (no pipe / an
151/// unmodeled producer) — in which case the consumer worst-cases the injected operand.
152pub fn stdin_item_repr() -> Option<String> {
153    STDIN_REPR.with(|v| v.borrow().last().cloned())
154}
155
156pub struct StdinReprGuard;
157
158impl Drop for StdinReprGuard {
159    fn drop(&mut self) {
160        STDIN_REPR.with(|v| {
161            v.borrow_mut().pop();
162        });
163    }
164}
165
166/// Expand any bound loop variable (`$name` / `${name}`) in `path` to its representative list
167/// item — the read representative when `want_write` is false, the write representative when
168/// true. Unbound `$…` is left untouched (so it still fail-closes to machine). Returns `path`
169/// unchanged when nothing is bound.
170pub fn expand_vars(path: &str, want_write: bool) -> Cow<'_, str> {
171    if !path.contains('$') {
172        return Cow::Borrowed(path);
173    }
174    let replaced = LOOP_VARS.with(|lv| {
175        VARS.with(|v| {
176            let loops = lv.borrow();
177            let vars = v.borrow();
178            if loops.is_empty() && vars.is_empty() {
179                None
180            } else {
181                expand_with(path, &loops, &vars, want_write)
182            }
183        })
184    });
185    replaced.map_or(Cow::Borrowed(path), Cow::Owned)
186}
187
188fn expand_with(path: &str, loops: &[LoopVar], vars: &[VarBinding], want_write: bool) -> Option<String> {
189    let mut out = String::with_capacity(path.len());
190    let mut rest = path;
191    let mut replaced = false;
192    while let Some(dollar) = rest.find('$') {
193        out.push_str(&rest[..dollar]);
194        let after = &rest[dollar + 1..];
195        match parse_var(after) {
196            Some((name, consumed)) => {
197                // Loop bindings first (they carry read/write reprs), then assignment/positional
198                // bindings; innermost/latest wins in each. Unbound in BOTH → left untouched.
199                if let Some(lv) = loops.iter().rev().find(|v| v.name == name) {
200                    out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
201                    replaced = true;
202                } else if let Some(vb) = vars.iter().rev().find(|v| v.name == name) {
203                    out.push_str(&vb.value);
204                    replaced = true;
205                } else {
206                    out.push('$');
207                    out.push_str(&after[..consumed]);
208                }
209                rest = &after[consumed..];
210            }
211            None => {
212                out.push('$');
213                rest = after;
214            }
215        }
216    }
217    out.push_str(rest);
218    replaced.then_some(out)
219}
220
221/// Parse a shell variable name immediately after a `$`: `name`, `{name}`, a single-digit positional
222/// (`$1`; bash reads `$12` as `$1` then `2`), or a braced positional (`${10}`). Returns the name and
223/// how many bytes of `after` it consumed, or `None` if it isn't a variable reference.
224fn parse_var(after: &str) -> Option<(&str, usize)> {
225    if let Some(braced) = after.strip_prefix('{') {
226        let close = braced.find('}')?;
227        let name = &braced[..close];
228        is_var_name(name).then_some((name, close + 2)) // '{' + name + '}'
229    } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
230        Some((&after[..1], 1)) // unbraced positional: exactly one digit
231    } else {
232        let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
233        let name = &after[..len];
234        is_var_name(name).then_some((name, len))
235    }
236}
237
238/// A `${…}` interior is a variable name — an identifier (`[A-Za-z_][A-Za-z0-9_]*`) OR an all-digit
239/// positional (`${10}`).
240fn is_var_name(s: &str) -> bool {
241    if s.is_empty() {
242        return false;
243    }
244    if s.bytes().all(|b| b.is_ascii_digit()) {
245        return true;
246    }
247    let mut bytes = s.bytes();
248    matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_')
249        && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
250}
251
252/// Resolve a path argument for classification against the ambient `cwd`/`root`. Returns a
253/// path the *existing* classifiers (`classify_locus`, `is_safe_write_target`) can score
254/// unchanged. When `cwd` and `root` are both known and absolute, a **relative** path is lexically
255/// joined onto `cwd` (no filesystem access) and an **absolute** path is normalized in place; then
256/// either way, if the result is inside `root` it comes back as a **root-relative** path (so the
257/// classifiers see "worktree"), and if it escaped `root` (e.g. `cwd` is `/etc`, or an absolute
258/// `/etc/hosts`) it comes back **absolute** (so they see `machine`/etc.). This makes the absolute
259/// and relative spellings of the SAME in-root file classify identically — safety on the OPERATION,
260/// not the SYNTAX. A `~` (home) or `$`-unpinnable path, or no context, is returned as-is.
261pub fn resolve(path: &str) -> Cow<'_, str> {
262    // Home (`~`) is handled by the classifiers directly; a `$` path can't be joined at all.
263    if path.is_empty() || path.starts_with('~') || path.contains('$') {
264        return Cow::Borrowed(path);
265    }
266    let resolved = CURRENT.with(|c| {
267        let ctx = c.borrow();
268        match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
269            (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
270                // Relative → join onto cwd; absolute → normalize in place. Then express relative
271                // to root if inside (worktree), else absolute.
272                let abs = if path.starts_with('/') {
273                    lexical_join("/", path)
274                } else {
275                    lexical_join(cwd, path)
276                };
277                Some(express_relative_to_root(&abs, root))
278            }
279            _ => None,
280        }
281    });
282    resolved.map_or(Cow::Borrowed(path), Cow::Owned)
283}
284
285/// Resolve a `cd` target to a new absolute working directory, or `None` if it can't be
286/// pinned statically (`cd`, `cd -`, `cd ~…`, `cd $VAR`) — the caller then leaves the running
287/// cwd unchanged. `cur` is the current cwd, needed to resolve a *relative* target. Used by
288/// intra-line `cd` tracking (HP-19 #2).
289pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
290    if target.starts_with('~') || target.contains('$') {
291        return None; // home / unpinnable
292    }
293    if target.starts_with('/') {
294        return Some(lexical_join("/", target)); // absolute — normalize
295    }
296    cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, target)) // relative
297}
298
299/// Express an absolute `abs` path relative to `root`: `.` if it IS the root, a root-relative path
300/// if it's inside (classified as worktree), or the absolute path unchanged if it escaped (classified
301/// as machine/etc.). The `inside.starts_with('/')` guard prevents a sibling like `/proj-evil` from
302/// matching root `/proj` by bare string prefix.
303fn express_relative_to_root(abs: &str, root: &str) -> String {
304    let root = root.trim_end_matches('/');
305    if abs == root {
306        return ".".to_string(); // the project root itself
307    }
308    match abs.strip_prefix(root) {
309        Some(inside) if inside.starts_with('/') => inside.trim_start_matches('/').to_string(),
310        _ => abs.to_string(),
311    }
312}
313
314/// Join a relative path onto an absolute base, resolving `.` and `..` purely lexically. A
315/// `..` that would climb above `/` is clamped there.
316fn lexical_join(base: &str, rel: &str) -> String {
317    let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
318    for seg in rel.split('/') {
319        match seg {
320            "" | "." => {}
321            ".." => {
322                parts.pop();
323            }
324            s => parts.push(s),
325        }
326    }
327    format!("/{}", parts.join("/"))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn no_context_leaves_paths_unchanged() {
336        assert_eq!(resolve("./x"), "./x");
337        assert_eq!(resolve("config"), "config");
338        assert_eq!(resolve("/etc/x"), "/etc/x");
339    }
340
341    #[test]
342    fn relative_inside_the_project_stays_worktree_relative() {
343        let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
344        assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
345        assert_eq!(resolve("./y"), "sub/y");
346        assert_eq!(resolve("../z"), "z", ".. that stays inside root");
347    }
348
349    #[test]
350    fn relative_outside_the_project_becomes_absolute() {
351        let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()) });
352        assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
353        assert_eq!(resolve("passwd"), "/etc/passwd");
354        assert_eq!(resolve("*"), "/etc/*");
355    }
356
357    #[test]
358    fn dotdot_escaping_the_project_becomes_absolute() {
359        let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()) });
360        assert_eq!(resolve("../../../etc/x"), "/etc/x");
361    }
362
363    #[test]
364    fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
365        let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
366        // absolute INSIDE root → root-relative (worktree), matching the relative spelling
367        assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
368        assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
369        assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
370        assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
371        // absolute OUTSIDE root → unchanged (classified as machine)
372        assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
373        assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
374        assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
375        assert_eq!(
376            resolve("/home/u/proj-evil/secret"), "/home/u/proj-evil/secret",
377            "a sibling dir is not confused for inside by bare string prefix",
378        );
379        // home / unpinnable → returned as-is (the classifiers handle these)
380        assert_eq!(resolve("$HOME/x"), "$HOME/x");
381        assert_eq!(resolve("~/x"), "~/x");
382    }
383
384    #[test]
385    fn loop_var_expands_to_its_representative_per_face() {
386        let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
387        assert_eq!(expand_vars("$f", false), "read_item");
388        assert_eq!(expand_vars("$f", true), "write_item");
389        assert_eq!(expand_vars("${f}", false), "read_item");
390        assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
391        assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
392        assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
393        assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
394        assert_eq!(expand_vars("plain", false), "plain");
395    }
396
397    #[test]
398    fn loop_var_binding_is_scoped_and_nests() {
399        assert_eq!(expand_vars("$f", false), "$f", "no binding");
400        {
401            let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
402            {
403                let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
404                assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
405            }
406            assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
407        }
408        assert_eq!(expand_vars("$f", false), "$f", "all popped");
409    }
410
411    #[test]
412    fn the_guard_restores_on_drop() {
413        {
414            let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()) });
415            assert_eq!(resolve("x"), "/etc/x");
416        }
417        assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
418    }
419}