Skip to main content

taskfleet_core/
paths.rs

1//! Directory layout helpers for a single run.
2
3use std::fs::OpenOptions;
4use std::io::ErrorKind;
5use std::path::{Path, PathBuf};
6
7use crate::error::{Error, Result};
8use crate::schema::{NodeId, RunId};
9
10/// Apply `O_NOFOLLOW` to `opts` on Unix, so opening an existing symlink at the
11/// path's *final* component fails atomically (`ELOOP`) instead of following it.
12///
13/// This closes the **file-level** half of the [`reject_symlink`](crate::paths) check-then-open
14/// TOCTOU window: even if an attacker swaps the leaf for a symlink in the gap
15/// between the `symlink_metadata` check and this open, the kernel refuses to
16/// traverse it at open time. The complementary **directory-level** half — a
17/// swapped *intermediate* component — is still covered only by the per-level
18/// `symlink_metadata` checks (`O_NOFOLLOW` does not constrain intermediate
19/// components; that needs Linux-only `openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS)`,
20/// deliberately deferred). Together the two guards cover the practical attack
21/// surface under the MVP per-user-`0700` trust model.
22///
23/// Returns `opts` for call-chaining. No-op on non-Unix, where the
24/// `symlink_metadata` check is the only guard (Windows reparse points are out
25/// of scope — taskfleet targets darwin/linux).
26pub fn nofollow(opts: &mut OpenOptions) -> &mut OpenOptions {
27    #[cfg(unix)]
28    {
29        use std::os::unix::fs::OpenOptionsExt;
30        opts.custom_flags(libc::O_NOFOLLOW);
31    }
32    opts
33}
34
35/// Reject `path` when it exists and is a symlink — best-effort containment so a
36/// replaced run-tree component cannot redirect a read or write outside the run
37/// directory. The error is built by `mk_err`, letting callers attach the right
38/// variant (run dir / subdir / file). An absent path is accepted: a
39/// not-yet-created file or subdir is normal, and the caller's open will fault or
40/// create it as usual. Any other `symlink_metadata` failure surfaces as
41/// [`Error::Io`].
42///
43/// `symlink_metadata` does not follow the *final* path component but does follow
44/// every *intermediate* one. Callers therefore guard each level they care about
45/// in its own call (root, then subdir, then file) — checking only the leaf would
46/// silently follow a symlinked parent. A broken symlink (target absent) is still
47/// reported as a symlink and rejected; only a path whose own final component is
48/// absent yields `NotFound` → `Ok`.
49///
50/// **Scope.** This guards the run directory and everything *inside* it. Symlinks
51/// at or *above* the run root — `<root>/runs`, `<root>`, `$HOME` — are explicitly
52/// out of scope: the state root is `$HOME/.taskfleet/`, a trusted per-user
53/// `0700` directory with no shared writers, so its ancestry is assumed intact.
54///
55/// **Residual TOCTOU gap.** This is check-then-open: a pure TOCTOU attacker can
56/// swap `path` for a symlink in the window between this `symlink_metadata` call
57/// and the caller's subsequent open — and, across the per-level calls, swap an
58/// already-checked parent so a later level resolves through it. Callers that
59/// open the leaf pair this check with [`nofollow`] (`O_NOFOLLOW`), which closes
60/// the **file-level** half of that window atomically at open time; the
61/// **directory-level** half (a swapped intermediate component) remains covered
62/// only by these per-level checks. Closing that last half needs Linux-only
63/// `openat2` (`RESOLVE_BENEATH` / `RESOLVE_NO_SYMLINKS`), deliberately deferred —
64/// the two portable guards cover the practical attack surface for the MVP
65/// per-user-`0700` trust model.
66pub(crate) fn reject_symlink(path: &Path, mk_err: impl FnOnce() -> Error) -> Result<()> {
67    match std::fs::symlink_metadata(path) {
68        Ok(md) if md.file_type().is_symlink() => Err(mk_err()),
69        Ok(_) => Ok(()),
70        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
71        Err(e) => Err(Error::io(path, e)),
72    }
73}
74
75/// Validate that `run_id` is a lowercase, ULID-shaped Crockford base32 string.
76///
77/// Thin wrapper over [`RunId::parse_str`] kept for the `validate_run_id` call
78/// sites that only need a yes/no answer in [`crate::Error`] terms. The
79/// constraint mirrors what [`crate::new_run_id`] emits: 26 lowercase Crockford
80/// base32 characters whose first character keeps the encoded timestamp within
81/// ULID's 48-bit range. Storing only validated ids lets the event envelope
82/// carry `run_id` directly instead of re-deriving it from a (possibly
83/// symlinked or non-canonical) directory name.
84pub fn validate_run_id(run_id: &str) -> Result<()> {
85    RunId::parse_str(run_id)
86        .map(|_| ())
87        .map_err(|e| Error::InvalidRunId {
88            run_id: run_id.to_string(),
89            reason: e.to_string(),
90        })
91}
92
93/// Per-run paths anchored on `<root>/runs/<run-id>/`.
94#[derive(Clone)]
95pub struct RunPaths {
96    /// The run's root directory; every other path is derived from it.
97    pub root: PathBuf,
98    /// The validated run id this directory belongs to. Carried explicitly so
99    /// event envelopes never re-derive it from `root.file_name()`.
100    pub run_id: RunId,
101}
102
103impl RunPaths {
104    /// Construct paths for `root` (the run directory) carrying a validated
105    /// `run_id`. Rejects malformed ids up front so every downstream event
106    /// envelope and projection is stamped with a well-formed id, and rejects a
107    /// run root that is a symlink ([`Error::SymlinkRunDir`]) so a replaced run
108    /// directory cannot redirect writes outside the run tree. An absent root is
109    /// fine — a fresh run is created later; only an existing *symlink* is
110    /// refused. See [`reject_symlink`](crate::paths) for the best-effort/TOCTOU caveat.
111    pub fn new(root: impl Into<PathBuf>, run_id: impl Into<String>) -> Result<Self> {
112        let run_id = run_id.into();
113        let rid = RunId::parse_str(&run_id).map_err(|e| Error::InvalidRunId {
114            run_id,
115            reason: e.to_string(),
116        })?;
117        let root = root.into();
118        reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
119        Ok(Self { root, run_id: rid })
120    }
121
122    /// Construct paths from an already-validated [`RunId`], skipping the
123    /// re-parse [`RunPaths::new`] does (but *not* the symlink-root check —
124    /// that one `symlink_metadata` is negligible and is the production CLI's
125    /// only construction-time guard, since this is the constructor it uses).
126    /// `root` must be the run directory (typically [`run_dir`]'s output for this
127    /// same id). Rejects a symlinked root with [`Error::SymlinkRunDir`].
128    pub fn from_validated(root: impl Into<PathBuf>, run_id: RunId) -> Result<Self> {
129        let root = root.into();
130        reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
131        Ok(Self { root, run_id })
132    }
133
134    /// Reject this run's root if it is a symlink ([`Error::SymlinkRunDir`]).
135    ///
136    /// Both constructors already run this check, but a long-lived [`RunPaths`]
137    /// can be swapped under after construction, so every projection / event /
138    /// lock access re-guards the root. Cheap (one `symlink_metadata`) and
139    /// best-effort — see [`reject_symlink`].
140    pub(crate) fn guard_root(&self) -> Result<()> {
141        reject_symlink(&self.root, || Error::SymlinkRunDir {
142            path: self.root.clone(),
143        })
144    }
145
146    /// `events.jsonl` path, guarding the run root and the event log itself
147    /// against symlink redirection ([`Error::SymlinkStateFile`]). The event
148    /// log is the run's source of truth and its highest-leverage write, so
149    /// every append/recover routes through here rather than [`RunPaths::events`]
150    /// directly. Best-effort — see [`reject_symlink`].
151    pub(crate) fn checked_events(&self) -> Result<PathBuf> {
152        self.guard_root()?;
153        let p = self.events();
154        reject_symlink(&p, || Error::SymlinkStateFile {
155            name: "events",
156            path: p.clone(),
157        })?;
158        Ok(p)
159    }
160
161    /// Path to the run manifest (`manifest.json`).
162    pub fn manifest(&self) -> PathBuf {
163        self.root.join("manifest.json")
164    }
165
166    /// Path to the append-only event log (`events.jsonl`).
167    pub fn events(&self) -> PathBuf {
168        self.root.join("events.jsonl")
169    }
170
171    /// Path to the advisory `flock` file (`.lock`) guarding this run.
172    pub fn lock(&self) -> PathBuf {
173        self.root.join(".lock")
174    }
175
176    /// Path to the `nodes/` directory holding per-node projection files.
177    pub fn nodes_dir(&self) -> PathBuf {
178        self.root.join("nodes")
179    }
180
181    /// Path to a single node's projection file (`nodes/<node-id>.json`).
182    ///
183    /// Takes a validated [`NodeId`], so the filename can never contain `/` or
184    /// `..` and the result can never escape `nodes/`.
185    pub fn node(&self, node_id: &NodeId) -> PathBuf {
186        self.nodes_dir().join(format!("{}.json", node_id.as_str()))
187    }
188
189    /// Path to the supervisor pid file (`supervisor.pid`).
190    pub fn supervisor_pid(&self) -> PathBuf {
191        self.root.join("supervisor.pid")
192    }
193
194    /// Path to the durable capture of the agent's tmux pane
195    /// (`agent.log`). The supervisor tees the worker pane here via
196    /// `tmux pipe-pane` right after spawn confirmation so a post-mortem
197    /// survives teardown — the file lives in the run dir, NOT the worktree,
198    /// so it persists after the tmux window and worktree are removed.
199    pub fn agent_log(&self) -> PathBuf {
200        self.root.join("agent.log")
201    }
202}
203
204/// Compose the standard run directory under `<root>/runs/<run-id>`.
205///
206/// Takes a validated [`RunId`] so this run-level path constructor cannot be
207/// handed a `..` or absolute component — closing the same traversal vector the
208/// per-run [`RunPaths`] helpers close for node ids.
209pub fn run_dir(root: &Path, run_id: &RunId) -> PathBuf {
210    root.join("runs").join(run_id.as_str())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn accepts_a_freshly_generated_run_id() {
219        let id = crate::new_run_id();
220        assert!(
221            validate_run_id(&id).is_ok(),
222            "generator must satisfy validator: {id}"
223        );
224        let paths = RunPaths::new("/tmp/x", id.clone()).expect("valid run_id");
225        assert_eq!(paths.run_id.as_str(), id);
226    }
227
228    #[test]
229    fn validator_stays_in_lockstep_with_the_generator() {
230        // Guards against drift between `new_run_id()` and the hand-rolled
231        // validator: every id the generator can emit must validate, including
232        // ones whose timestamp pushes the first character toward the bound.
233        for _ in 0..2000 {
234            let id = crate::new_run_id();
235            assert!(validate_run_id(&id).is_ok(), "generator emitted {id:?}");
236        }
237    }
238
239    #[test]
240    fn accepts_the_first_char_boundary_and_rejects_just_past_it() {
241        assert!(validate_run_id("7zzzzzzzzzzzzzzzzzzzzzzzzz").is_ok());
242        assert!(matches!(
243            RunPaths::new("/tmp/x", "8zzzzzzzzzzzzzzzzzzzzzzzzz"),
244            Err(Error::InvalidRunId { .. })
245        ));
246    }
247
248    #[cfg(unix)]
249    #[test]
250    fn rejects_a_symlinked_run_dir_at_construction() {
251        // A symlink to a real directory: the id is well-formed, but the run
252        // root is a symlink, so `new` must refuse to follow it.
253        use std::os::unix::fs::symlink;
254        use tempfile::TempDir;
255        let tmp = TempDir::new().unwrap();
256        let real = tmp.path().join("real");
257        std::fs::create_dir_all(&real).unwrap();
258        let link = tmp.path().join("link");
259        symlink(&real, &link).unwrap();
260        assert!(matches!(
261            RunPaths::new(&link, "01jxsnap000000000000000000"),
262            Err(Error::SymlinkRunDir { path }) if path == link
263        ));
264    }
265
266    #[test]
267    fn accepts_a_real_directory_run_root() {
268        // A real (non-symlink) existing directory is fine — only symlinks are
269        // refused, not pre-existing run dirs.
270        use tempfile::TempDir;
271        let tmp = TempDir::new().unwrap();
272        let dir = tmp.path().join("run");
273        std::fs::create_dir_all(&dir).unwrap();
274        assert!(RunPaths::new(&dir, "01jxsnap000000000000000000").is_ok());
275    }
276
277    #[test]
278    fn rejects_malformed_run_ids_at_construction() {
279        for bad in [
280            "tooshort",                    // wrong length
281            "01jxsnap0000000000000000000", // 27 chars, too long
282            "01JXSNAP000000000000000000",  // uppercase
283            "01jxiiiiiiiiiiiiiiiiiiiiii",  // `i` not in Crockford alphabet
284            "80000000000000000000000000",  // first char exceeds ULID range
285        ] {
286            assert!(
287                matches!(
288                    RunPaths::new("/tmp/x", bad),
289                    Err(Error::InvalidRunId { .. })
290                ),
291                "expected {bad:?} to be rejected",
292            );
293        }
294    }
295}