Skip to main content

safe_chains/pathctx/
folder.rs

1//! The unknown-folder mode of one evaluation: which folder level is in force, where each relative
2//! path is placed, and which command leaves wrote without naming a path
3//! (docs/design/unknown-folder-writes.md).
4//!
5//! Engaged only when a harness does not say which folder the command runs in, and the command
6//! is classified at `targets::UNKNOWN_WORKDIR`. At `reads` nothing here changes a verdict. Above it:
7//!
8//! - a relative path is placed in the workspace when its anchor value and use are ones the level
9//!   approves (`anchor::placement`), and left in the unknown folder otherwise, where no write lands
10//!   inside anything;
11//! - a command leaf whose verdict is a write is approved only when that write is accounted for: it
12//!   wrote a path it named (and that path was placed or refused on its own), it declares what it
13//!   writes in its folder (`writes_cwd`, `executor = "project"`), or a command nested in it was
14//!   accounted for. Anything else is refused, so a writer nobody has labelled fails closed.
15
16use std::cell::{Cell, RefCell};
17
18use super::anchor::{self, Anchor, FolderLevel, Use};
19use crate::parse::Token;
20use crate::verdict::{SafetyLevel, Verdict};
21
22/// What the mode decided about one path or one command, for `--explain`.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum Note {
25    Path { path: String, anchor: Anchor, use_: Use, placed: bool },
26    Leaf { command: String, anchor: Option<Anchor>, admitted: bool },
27}
28
29#[derive(Default)]
30struct Frame {
31    named_write: bool,
32    nested_accounted: bool,
33    declared: Option<Anchor>,
34}
35
36thread_local! {
37    static LEVEL: Cell<Option<FolderLevel>> = const { Cell::new(None) };
38    static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
39    static NOTES: RefCell<Vec<Note>> = const { RefCell::new(Vec::new()) };
40}
41
42/// Put `level` in force for the guard's lifetime and start a fresh record. Restored on drop.
43#[must_use]
44pub fn enter(level: FolderLevel) -> Guard {
45    NOTES.with(|n| n.borrow_mut().clear());
46    Guard(LEVEL.with(|l| l.replace(Some(level))))
47}
48
49pub struct Guard(Option<FolderLevel>);
50
51impl Drop for Guard {
52    fn drop(&mut self) {
53        LEVEL.with(|l| l.set(self.0));
54    }
55}
56
57/// The folder level in force, or `None` outside the mode.
58pub fn level() -> Option<FolderLevel> {
59    LEVEL.with(Cell::get)
60}
61
62/// Whether writes are being judged at all: a level above `reads` is in force.
63pub fn judges_writes() -> bool {
64    level().is_some_and(|l| l > FolderLevel::Reads)
65}
66
67/// What this evaluation decided, in order, for `--explain`.
68pub fn notes() -> Vec<Note> {
69    NOTES.with(|n| n.borrow().clone())
70}
71
72/// Whether `cwd` is the unknown folder or a directory below it (`cd sub` from there).
73pub fn is_unknown(cwd: &str) -> bool {
74    let base = crate::targets::UNKNOWN_WORKDIR;
75    cwd.strip_prefix(base).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
76}
77
78/// The workspace-relative path to classify `path` as, when it is relative to an unknown `cwd` and
79/// the level places it; `None` leaves it in the unknown folder.
80///
81/// A path that climbs out of the folder, or cannot be read, is placed NOWHERE at every level, even
82/// for a read: joined onto the unknown folder it would name an ordinary directory, while the real
83/// parent of an unknown folder can be anything, another user's home included.
84pub(super) fn place(cwd: &str, path: &str, stated: Option<Use>) -> Option<String> {
85    let use_ = stated.unwrap_or(Use::Read);
86    let level = level()?;
87    if path.starts_with('/') || path.starts_with('~') || !is_unknown(cwd) {
88        return None;
89    }
90    let below = cwd[crate::targets::UNKNOWN_WORKDIR.len()..].trim_start_matches('/');
91    let relative = if below.is_empty() { path.to_string() } else { format!("{below}/{path}") };
92    let anchor = anchor::of_path(&relative);
93    let placed = anchor::placement(&relative, use_, level)
94        .filter(|p| !use_.mutates() || ((p != "." || declares_writes_in_its_folder()) && !items_in_scope()));
95    if level > FolderLevel::Reads && stated.is_some_and(Use::mutates) {
96        record(Note::Path { path: relative.clone(), anchor, use_, placed: placed.is_some() });
97    }
98    match placed {
99        None if anchor == Anchor::RelativeUnplaced || use_ == Use::Read => Some(NOWHERE.to_string()),
100        other => other,
101    }
102}
103
104/// Whether the command leaf being judged says what it writes in its folder (`writes_cwd` source or
105/// output). Only such a tool may write to the folder ITSELF (`gofmt -w .`): a copy or a sync into
106/// `.` writes names it brings with it (`cp /tmp/.zshrc .`), and the folder could be `~`.
107fn declares_writes_in_its_folder() -> bool {
108    FRAMES.with(|f| {
109        f.borrow()
110            .last()
111            .and_then(|t| t.declared)
112            .is_some_and(|a| matches!(a, Anchor::ImplicitSource | Anchor::ImplicitOutput))
113    })
114}
115
116/// Whether a write may be naming an item that arrives at run time: an `xargs` item on stdin, or a
117/// loop variable (`while read f`, `for f in …`). The classifier sees a stand-in for it, an ordinary
118/// name, while the real item can be `.zshrc` (`ls -A | xargs -I{} sh -c 'echo x >> {}'`).
119fn items_in_scope() -> bool {
120    super::stdin_item_repr().is_some() || super::LOOP_VARS.with(|v| !v.borrow().is_empty())
121}
122
123/// The command leaf being judged wrote to a path it named.
124pub(super) fn note_named_write() {
125    FRAMES.with(|f| {
126        if let Some(top) = f.borrow_mut().last_mut() {
127            top.named_write = true;
128        }
129    });
130}
131
132/// A leaf classifier that judges each leaf it classifies, for a caller that hands one on.
133pub(crate) fn judging(with_env: bool, classify: fn(&[Token]) -> Verdict) -> impl Fn(&[Token]) -> Verdict {
134    move |tokens| judge_leaf(tokens, with_env, || classify(tokens))
135}
136
137/// Judge one command leaf: run `classify`, and when its verdict is a write made in the unknown
138/// folder that nothing accounts for, refuse it.
139pub(crate) fn judge_leaf(tokens: &[Token], with_env: bool, classify: impl FnOnce() -> Verdict) -> Verdict {
140    let Some(level) = level().filter(|l| *l > FolderLevel::Reads) else {
141        return classify();
142    };
143    let here_unknown = super::cwd().is_some_and(|c| is_unknown(&c));
144    let declared = crate::registry::cwd_writes(tokens);
145    let frame = FrameGuard::push(declared);
146    let verdict = classify();
147    let frame = frame.pop();
148    let writes = matches!(verdict, Verdict::Allowed(l) if l > SafetyLevel::SafeRead);
149    if !writes {
150        return verdict;
151    }
152    if !here_unknown {
153        mark_parent_accounted();
154        return verdict;
155    }
156    let names_its_writes = declared == Some(Anchor::NamesItsWrites) && frame.named_write;
157    // An assignment in front can move a write the declaration describes (`GIT_INDEX_FILE=.zshrc git
158    // add .`, `CARGO_TARGET_DIR=../.. cargo build`), so it voids an implicit declaration.
159    let implicit = !with_env && declared.is_some_and(|a| level.admits_implicit(a));
160    let accounted = implicit || names_its_writes || frame.nested_accounted;
161    let command = tokens.iter().take(4).map(Token::as_str).collect::<Vec<_>>().join(" ");
162    record(Note::Leaf { command, anchor: declared, admitted: accounted });
163    if accounted {
164        mark_parent_accounted();
165        verdict
166    } else {
167        Verdict::Denied
168    }
169}
170
171fn mark_parent_accounted() {
172    FRAMES.with(|f| {
173        if let Some(top) = f.borrow_mut().last_mut() {
174            top.nested_accounted = true;
175        }
176    });
177}
178
179fn record(note: Note) {
180    NOTES.with(|n| {
181        let mut notes = n.borrow_mut();
182        if notes.len() < MAX_NOTES && !notes.contains(&note) {
183            notes.push(note);
184        }
185    });
186}
187
188/// What a path placed nowhere is classified as: a component the locus guard treats as unpinnable,
189/// so a read of it is one the shield cannot clear and a write of it lands nowhere approvable.
190const NOWHERE: &str = "__SAFE_CHAINS_CMDSUB__/outside-an-unknown-folder";
191
192/// A long chain still explains its first writes; past this the record stops growing.
193const MAX_NOTES: usize = 64;
194
195/// Pops its frame even when the classification inside unwinds, so a later evaluation on the thread
196/// does not inherit it.
197struct FrameGuard(bool);
198
199impl FrameGuard {
200    fn push(declared: Option<Anchor>) -> FrameGuard {
201        FRAMES.with(|f| f.borrow_mut().push(Frame { declared, ..Frame::default() }));
202        FrameGuard(true)
203    }
204
205    fn pop(mut self) -> Frame {
206        self.0 = false;
207        FRAMES.with(|f| f.borrow_mut().pop()).unwrap_or_default()
208    }
209}
210
211impl Drop for FrameGuard {
212    fn drop(&mut self) {
213        if self.0 {
214            FRAMES.with(|f| f.borrow_mut().pop());
215        }
216    }
217}
218
219#[cfg(test)]
220#[path = "folder_tests.rs"]
221mod tests;
222
223#[cfg(test)]
224#[path = "folder_soundness_tests.rs"]
225mod soundness;