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}