Skip to main content

octl_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::{DiscussionId, NodeId, ProposalId, 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 — orchestratectl 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/.orchestratectl/`, 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>/`.
94pub struct RunPaths {
95    /// The run's root directory; every other path is derived from it.
96    pub root: PathBuf,
97    /// The validated run id this directory belongs to. Carried explicitly so
98    /// event envelopes never re-derive it from `root.file_name()`.
99    pub run_id: RunId,
100}
101
102impl RunPaths {
103    /// Construct paths for `root` (the run directory) carrying a validated
104    /// `run_id`. Rejects malformed ids up front so every downstream event
105    /// envelope and projection is stamped with a well-formed id, and rejects a
106    /// run root that is a symlink ([`Error::SymlinkRunDir`]) so a replaced run
107    /// directory cannot redirect writes outside the run tree. An absent root is
108    /// fine — a fresh run is created later; only an existing *symlink* is
109    /// refused. See [`reject_symlink`](crate::paths) for the best-effort/TOCTOU caveat.
110    pub fn new(root: impl Into<PathBuf>, run_id: impl Into<String>) -> Result<Self> {
111        let run_id = run_id.into();
112        let rid = RunId::parse_str(&run_id).map_err(|e| Error::InvalidRunId {
113            run_id,
114            reason: e.to_string(),
115        })?;
116        let root = root.into();
117        reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
118        Ok(Self { root, run_id: rid })
119    }
120
121    /// Construct paths from an already-validated [`RunId`], skipping the
122    /// re-parse [`RunPaths::new`] does (but *not* the symlink-root check —
123    /// that one `symlink_metadata` is negligible and is the production CLI's
124    /// only construction-time guard, since this is the constructor it uses).
125    /// `root` must be the run directory (typically [`run_dir`]'s output for this
126    /// same id). Rejects a symlinked root with [`Error::SymlinkRunDir`].
127    pub fn from_validated(root: impl Into<PathBuf>, run_id: RunId) -> Result<Self> {
128        let root = root.into();
129        reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
130        Ok(Self { root, run_id })
131    }
132
133    /// Reject this run's root if it is a symlink ([`Error::SymlinkRunDir`]).
134    ///
135    /// Both constructors already run this check, but a long-lived [`RunPaths`]
136    /// can be swapped under after construction, so every projection / event /
137    /// lock access re-guards the root. Cheap (one `symlink_metadata`) and
138    /// best-effort — see [`reject_symlink`].
139    pub(crate) fn guard_root(&self) -> Result<()> {
140        reject_symlink(&self.root, || Error::SymlinkRunDir {
141            path: self.root.clone(),
142        })
143    }
144
145    /// `events.jsonl` path, guarding the run root and the event log itself
146    /// against symlink redirection ([`Error::SymlinkStateFile`]). The event
147    /// log is the run's source of truth and its highest-leverage write, so
148    /// every append/recover routes through here rather than [`RunPaths::events`]
149    /// directly. Best-effort — see [`reject_symlink`].
150    pub(crate) fn checked_events(&self) -> Result<PathBuf> {
151        self.guard_root()?;
152        let p = self.events();
153        reject_symlink(&p, || Error::SymlinkStateFile {
154            name: "events",
155            path: p.clone(),
156        })?;
157        Ok(p)
158    }
159
160    /// Path to the run manifest (`manifest.json`).
161    pub fn manifest(&self) -> PathBuf {
162        self.root.join("manifest.json")
163    }
164
165    /// Path to the append-only event log (`events.jsonl`).
166    pub fn events(&self) -> PathBuf {
167        self.root.join("events.jsonl")
168    }
169
170    /// Path to the advisory `flock` file (`.lock`) guarding this run.
171    pub fn lock(&self) -> PathBuf {
172        self.root.join(".lock")
173    }
174
175    /// Path to the `nodes/` directory holding per-node projection files.
176    pub fn nodes_dir(&self) -> PathBuf {
177        self.root.join("nodes")
178    }
179
180    /// Path to a single node's projection file (`nodes/<node-id>.json`).
181    ///
182    /// Takes a validated [`NodeId`], so the filename can never contain `/` or
183    /// `..` and the result can never escape `nodes/`.
184    pub fn node(&self, node_id: &NodeId) -> PathBuf {
185        self.nodes_dir().join(format!("{}.json", node_id.as_str()))
186    }
187
188    /// Path to the `discussions/` directory.
189    pub fn discussions_dir(&self) -> PathBuf {
190        self.root.join("discussions")
191    }
192
193    /// Path to a single discussion file (`discussions/<id>.json`).
194    ///
195    /// Takes a validated [`DiscussionId`], so the result can never escape
196    /// `discussions/`.
197    pub fn discussion(&self, id: &DiscussionId) -> PathBuf {
198        self.discussions_dir().join(format!("{}.json", id.as_str()))
199    }
200
201    /// Path to the `spinoffs/` directory.
202    pub fn spinoffs_dir(&self) -> PathBuf {
203        self.root.join("spinoffs")
204    }
205
206    /// Path to a single spin-off proposal file (`spinoffs/<id>.json`).
207    ///
208    /// Takes a validated [`ProposalId`], so the result can never escape
209    /// `spinoffs/`.
210    pub fn spinoff(&self, id: &ProposalId) -> PathBuf {
211        self.spinoffs_dir().join(format!("{}.json", id.as_str()))
212    }
213
214    /// Path to the supervisor pid file (`supervisor.pid`).
215    pub fn supervisor_pid(&self) -> PathBuf {
216        self.root.join("supervisor.pid")
217    }
218
219    /// Path to the durable capture of the agent's tmux pane
220    /// (`agent.log`). The supervisor tees the worker pane here via
221    /// `tmux pipe-pane` right after spawn confirmation so a post-mortem
222    /// survives teardown — the file lives in the run dir, NOT the worktree,
223    /// so it persists after the tmux window and worktree are removed.
224    pub fn agent_log(&self) -> PathBuf {
225        self.root.join("agent.log")
226    }
227}
228
229/// Compose the standard run directory under `<root>/runs/<run-id>`.
230///
231/// Takes a validated [`RunId`] so this run-level path constructor cannot be
232/// handed a `..` or absolute component — closing the same traversal vector the
233/// per-run [`RunPaths`] helpers close for node/discussion/spinoff ids.
234pub fn run_dir(root: &Path, run_id: &RunId) -> PathBuf {
235    root.join("runs").join(run_id.as_str())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn accepts_a_freshly_generated_run_id() {
244        let id = crate::new_run_id();
245        assert!(
246            validate_run_id(&id).is_ok(),
247            "generator must satisfy validator: {id}"
248        );
249        let paths = RunPaths::new("/tmp/x", id.clone()).expect("valid run_id");
250        assert_eq!(paths.run_id.as_str(), id);
251    }
252
253    #[test]
254    fn validator_stays_in_lockstep_with_the_generator() {
255        // Guards against drift between `new_run_id()` and the hand-rolled
256        // validator: every id the generator can emit must validate, including
257        // ones whose timestamp pushes the first character toward the bound.
258        for _ in 0..2000 {
259            let id = crate::new_run_id();
260            assert!(validate_run_id(&id).is_ok(), "generator emitted {id:?}");
261        }
262    }
263
264    #[test]
265    fn accepts_the_first_char_boundary_and_rejects_just_past_it() {
266        assert!(validate_run_id("7zzzzzzzzzzzzzzzzzzzzzzzzz").is_ok());
267        assert!(matches!(
268            RunPaths::new("/tmp/x", "8zzzzzzzzzzzzzzzzzzzzzzzzz"),
269            Err(Error::InvalidRunId { .. })
270        ));
271    }
272
273    #[cfg(unix)]
274    #[test]
275    fn rejects_a_symlinked_run_dir_at_construction() {
276        // A symlink to a real directory: the id is well-formed, but the run
277        // root is a symlink, so `new` must refuse to follow it.
278        use std::os::unix::fs::symlink;
279        use tempfile::TempDir;
280        let tmp = TempDir::new().unwrap();
281        let real = tmp.path().join("real");
282        std::fs::create_dir_all(&real).unwrap();
283        let link = tmp.path().join("link");
284        symlink(&real, &link).unwrap();
285        assert!(matches!(
286            RunPaths::new(&link, "01jxsnap000000000000000000"),
287            Err(Error::SymlinkRunDir { path }) if path == link
288        ));
289    }
290
291    #[test]
292    fn accepts_a_real_directory_run_root() {
293        // A real (non-symlink) existing directory is fine — only symlinks are
294        // refused, not pre-existing run dirs.
295        use tempfile::TempDir;
296        let tmp = TempDir::new().unwrap();
297        let dir = tmp.path().join("run");
298        std::fs::create_dir_all(&dir).unwrap();
299        assert!(RunPaths::new(&dir, "01jxsnap000000000000000000").is_ok());
300    }
301
302    #[test]
303    fn rejects_malformed_run_ids_at_construction() {
304        for bad in [
305            "tooshort",                    // wrong length
306            "01jxsnap0000000000000000000", // 27 chars, too long
307            "01JXSNAP000000000000000000",  // uppercase
308            "01jxiiiiiiiiiiiiiiiiiiiiii",  // `i` not in Crockford alphabet
309            "80000000000000000000000000",  // first char exceeds ULID range
310        ] {
311            assert!(
312                matches!(
313                    RunPaths::new("/tmp/x", bad),
314                    Err(Error::InvalidRunId { .. })
315                ),
316                "expected {bad:?} to be rejected",
317            );
318        }
319    }
320}