Skip to main content

octl_core/
projections.rs

1//! Read-side helpers for projection files.
2//!
3//! Each `read_*` here reads exactly one file and is coherent on its own (atomic
4//! rename — see [`crate::atomic`]). A caller that reads **several** files as one
5//! logical view (e.g. `manifest.json` together with the `nodes/` projection
6//! set, whose denormalized counters
7//! the reducer updates in the same locked mutation) must wrap the whole scan in
8//! [`crate::RunLock::with_shared_lock`] (`LOCK_SH`). That excludes the reducer's
9//! exclusive lock for the scan's duration, so the reader observes one committed
10//! snapshot rather than a half-applied update (design.md §4). The lock is
11//! released before the result is serialized.
12
13use std::path::{Path, PathBuf};
14
15use crate::atomic::write_json_atomic;
16use crate::error::{Error, Result};
17use crate::paths::{reject_symlink, RunPaths};
18use crate::schema::{Manifest, Node, NodeId, RunId, SUPPORTED_STATE_SCHEMAS};
19
20/// Resolve a projection file path while rejecting a symlinked run root, the
21/// symlinked subdir, or a symlinked file before the caller opens it — so a
22/// tampered run-tree component cannot redirect a read or write outside the run
23/// directory. `dir_name` names the containing subdir (for [`Error::SymlinkSubdir`])
24/// and `file_kind` names the projection type (for [`Error::SymlinkStateFile`]);
25/// both checks run after the run root is guarded. Best-effort containment with a
26/// check-then-open TOCTOU gap — see [`reject_symlink`].
27fn checked_file(
28    paths: &RunPaths,
29    subdir: PathBuf,
30    dir_name: &'static str,
31    file: PathBuf,
32    file_kind: &'static str,
33) -> Result<PathBuf> {
34    paths.guard_root()?;
35    reject_symlink(&subdir, || Error::SymlinkSubdir {
36        name: dir_name,
37        path: subdir.clone(),
38    })?;
39    reject_symlink(&file, || Error::SymlinkStateFile {
40        name: file_kind,
41        path: file.clone(),
42    })?;
43    Ok(file)
44}
45
46/// `manifest.json` path, guarding the run root and the manifest file itself.
47fn checked_manifest(paths: &RunPaths) -> Result<PathBuf> {
48    paths.guard_root()?;
49    let p = paths.manifest();
50    reject_symlink(&p, || Error::SymlinkStateFile {
51        name: "manifest",
52        path: p.clone(),
53    })?;
54    Ok(p)
55}
56
57/// `nodes/<id>.json` path with run-root, `nodes/`, and file symlink guards.
58fn checked_node(paths: &RunPaths, id: &NodeId) -> Result<PathBuf> {
59    checked_file(paths, paths.nodes_dir(), "nodes", paths.node(id), "node")
60}
61
62/// Read `path` into bytes with `O_NOFOLLOW`: a projection file replaced by a
63/// symlink fails the open (`ELOOP`) rather than redirecting the read. This is
64/// the file-level TOCTOU backstop to the `reject_symlink` check the `checked_*`
65/// resolvers run before calling in here — projection *writes* go via temp-file +
66/// rename (never opening the leaf), so the read is the projection's only
67/// follow-through-a-symlink surface. See [`crate::paths::nofollow`].
68fn read_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
69    use std::io::Read;
70    let mut opts = std::fs::OpenOptions::new();
71    opts.read(true);
72    crate::paths::nofollow(&mut opts);
73    let mut f = opts.open(path)?;
74    let mut buf = Vec::new();
75    f.read_to_end(&mut buf)?;
76    Ok(buf)
77}
78
79fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
80    let bytes = read_nofollow(path).map_err(|e| Error::io(path, e))?;
81    serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
82}
83
84fn read_json_opt<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
85    match read_nofollow(path) {
86        Ok(bytes) => Ok(Some(
87            serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))?,
88        )),
89        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
90        Err(e) => Err(Error::io(path, e)),
91    }
92}
93
94fn check_schema(path: &Path, found: u32) -> Result<()> {
95    if SUPPORTED_STATE_SCHEMAS.contains(&found) {
96        Ok(())
97    } else {
98        Err(Error::UnsupportedSchemaVersion {
99            path: path.to_path_buf(),
100            found,
101            supported: SUPPORTED_STATE_SCHEMAS.to_vec(),
102        })
103    }
104}
105
106/// Reject a projection whose body id (`body`) does not equal the filename key
107/// it was read under (`expected`). Both are already-validated id newtypes — a
108/// well-formed `nodes/n-0002.json` mis-filed as `nodes/n-0001.json` parses
109/// cleanly yet describes a different object, so handing it back would let a
110/// later keyed write clobber a third file. `kind` names the projection type so
111/// a caller can branch on the [`Error::CorruptProjection`] it produces; `path`
112/// localizes the offending file.
113fn check_key(path: &Path, kind: &'static str, expected: &str, body: &str) -> Result<()> {
114    if expected == body {
115        Ok(())
116    } else {
117        Err(Error::CorruptProjection {
118            kind,
119            path: path.to_path_buf(),
120            expected_id: expected.to_string(),
121            body_id: body.to_string(),
122        })
123    }
124}
125
126/// Reject a projection whose object `run_id` (`body`) does not equal the run
127/// the [`RunPaths`] is anchored on (`expected`). Fires on both sides: every
128/// `read_*` rejects a file that belongs to a foreign run before handing it
129/// back, and every `write_*` refuses to stamp a foreign run's id into this
130/// run's directory — feasible now that `RunPaths` carries a typed
131/// [`crate::RunId`]. Takes `&RunId` (not `&str`) so a caller cannot transpose
132/// the arguments or pass an unrelated id. `kind` is the run-id discriminator
133/// (`"node_run_id"`, etc.); `path` localizes the file.
134fn check_run_id(path: &Path, kind: &'static str, expected: &RunId, body: &RunId) -> Result<()> {
135    if expected == body {
136        Ok(())
137    } else {
138        Err(Error::CorruptProjection {
139            kind,
140            path: path.to_path_buf(),
141            expected_id: expected.to_string(),
142            body_id: body.to_string(),
143        })
144    }
145}
146
147/// Read and schema-validate the run manifest. Errors if it is missing.
148pub fn read_manifest(paths: &RunPaths) -> Result<Manifest> {
149    let p = checked_manifest(paths)?;
150    let m: Manifest = read_json(&p)?;
151    check_schema(&p, m.schema_version)?;
152    check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
153    Ok(m)
154}
155
156/// Read and schema-validate the run manifest, returning `None` if absent.
157///
158/// A present manifest whose `run_id` belongs to a foreign run is an error, not
159/// `None`: `_opt` means "missing file is fine", not "corrupt file is absent".
160pub fn read_manifest_opt(paths: &RunPaths) -> Result<Option<Manifest>> {
161    let p = checked_manifest(paths)?;
162    match read_json_opt::<Manifest>(&p)? {
163        Some(m) => {
164            check_schema(&p, m.schema_version)?;
165            check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
166            Ok(Some(m))
167        }
168        None => Ok(None),
169    }
170}
171
172/// Atomically write the run manifest (temp file + rename).
173///
174/// `pub(crate)`: projection writes belong to the reducer. External callers
175/// mutate state through [`crate::events::append_and_apply_event`] so a write
176/// can never bypass the event log or the run's `flock`.
177pub(crate) fn write_manifest(paths: &RunPaths, m: &Manifest) -> Result<()> {
178    let p = checked_manifest(paths)?;
179    check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
180    write_json_atomic(&p, m)
181}
182
183/// Read and schema-validate one node. Errors if it is missing.
184pub fn read_node(paths: &RunPaths, node_id: &NodeId) -> Result<Node> {
185    let p = checked_node(paths, node_id)?;
186    let n: Node = read_json(&p)?;
187    check_schema(&p, n.schema_version)?;
188    check_key(&p, "node", node_id.as_str(), n.node_id.as_str())?;
189    check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
190    Ok(n)
191}
192
193/// Read and schema-validate one node, returning `None` if absent.
194///
195/// A present node whose body id or `run_id` does not match where it lives is an
196/// error, not `None`: `_opt` covers a missing file, not a corrupt one.
197pub fn read_node_opt(paths: &RunPaths, node_id: &NodeId) -> Result<Option<Node>> {
198    let p = checked_node(paths, node_id)?;
199    match read_json_opt::<Node>(&p)? {
200        Some(n) => {
201            check_schema(&p, n.schema_version)?;
202            check_key(&p, "node", node_id.as_str(), n.node_id.as_str())?;
203            check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
204            Ok(Some(n))
205        }
206        None => Ok(None),
207    }
208}
209
210/// Atomically write a node's projection file, keyed by its `node_id`.
211///
212/// Stays `pub` (unlike the other `write_*` helpers) as the sanctioned
213/// lock-held composition path for the supervisor batch: the supervisor
214/// mirrors per-child report cursors and a child's `supervisor_pid` directly
215/// onto the node projection while holding the run's `flock` — fields no
216/// event/reducer path manages. Pair it with [`crate::RunLock`].
217pub fn write_node(paths: &RunPaths, n: &Node) -> Result<()> {
218    let p = checked_node(paths, &n.node_id)?;
219    check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
220    write_json_atomic(&p, n)
221}
222
223/// The manifest's denormalized counters, recomputed from projection state.
224///
225/// Returned by [`derive_counters`] as a single snapshot of the `nodes/`
226/// directory.
227pub(crate) struct DerivedCounters {
228    /// Number of node projection files.
229    pub node_count: u32,
230}
231
232/// Recompute the manifest's denormalized counters directly from the projection
233/// directories, so they are a pure function of projection state rather than an
234/// incrementally-maintained delta.
235///
236/// This is the heart of the counter-desync fix (issue
237/// `manifest-counter-desync`): [`crate::events::advance_applied_seq`] calls this
238/// whenever it advances the `applied_seq` watermark, so the counters persisted
239/// alongside the watermark always equal a fresh count of the projection state
240/// as it stands *after* an event's projection writes are committed. Deriving the
241/// counts removes any delta: drift is impossible because nothing is ever
242/// incremented.
243///
244/// Counting is best-effort under corruption: a directory that does not exist
245/// counts as empty, and a projection file that fails to read or parse is
246/// skipped rather than bricking every future append (`doctor` surfaces such
247/// anomalies). Only regular `*.json` files whose stem is a well-formed
248/// projection id are counted, so an in-flight atomic write (a hidden
249/// `.<name>.tmp.<pid>.<n>` tempfile) is never miscounted.
250pub(crate) fn derive_counters(paths: &RunPaths) -> Result<DerivedCounters> {
251    Ok(DerivedCounters {
252        node_count: count_node_files(paths)?,
253    })
254}
255
256/// Open `dir` for a counting walk: a missing directory yields `None` (count 0);
257/// a real `read_dir` failure propagates. Pairs with [`projection_id_stem`].
258fn open_projection_dir(dir: &Path) -> Result<Option<std::fs::ReadDir>> {
259    match std::fs::read_dir(dir) {
260        Ok(e) => Ok(Some(e)),
261        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
262        Err(e) => Err(Error::io(dir, e)),
263    }
264}
265
266/// The id-stem of a projection slot: `Some(stem)` for a regular `*.json` file,
267/// `None` for directories, non-`json` entries, and the hidden tempfiles atomic
268/// writes leave mid-rename. The caller decides whether `stem` is a valid id.
269fn projection_id_stem(ent: &std::fs::DirEntry) -> Option<String> {
270    if !ent.file_type().is_ok_and(|t| t.is_file()) {
271        return None;
272    }
273    let path = ent.path();
274    if path.extension().and_then(|s| s.to_str()) != Some("json") {
275        return None;
276    }
277    path.file_stem()
278        .and_then(|s| s.to_str())
279        .map(str::to_string)
280}
281
282/// Count node projection files: every regular `nodes/<node-id>.json` whose stem
283/// is a well-formed [`NodeId`]. A node file's mere existence means the node was
284/// created, so this needs no content read.
285fn count_node_files(paths: &RunPaths) -> Result<u32> {
286    let dir = paths.nodes_dir();
287    let Some(entries) = open_projection_dir(&dir)? else {
288        return Ok(0);
289    };
290    let mut n: u32 = 0;
291    for ent in entries {
292        let ent = ent.map_err(|e| Error::io(&dir, e))?;
293        if let Some(stem) = projection_id_stem(&ent) {
294            if NodeId::parse_str(&stem).is_ok() {
295                n = n.saturating_add(1);
296            }
297        }
298    }
299    Ok(n)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::schema::STATE_SCHEMA_VERSION;
306    use serde_json::{json, Value};
307    use tempfile::TempDir;
308
309    /// The run this run-directory belongs to, and a *different* well-formed run
310    /// id used to forge the cross-run mismatch the write guards reject.
311    const RUN: &str = "01jxsnap000000000000000000";
312    const FOREIGN_RUN: &str = "02jxsnap000000000000000000";
313
314    /// A run dir under a fresh tempdir, with the projection subdirectories
315    /// created so a hand-written file can be dropped at any key.
316    fn setup() -> (TempDir, RunPaths) {
317        let tmp = TempDir::new().unwrap();
318        let dir = tmp.path().join("run");
319        let paths = RunPaths::new(&dir, RUN).unwrap();
320        std::fs::create_dir_all(paths.nodes_dir()).unwrap();
321        (tmp, paths)
322    }
323
324    fn node_json(node_id: &str, run_id: &str) -> Value {
325        json!({
326            "schema_version": STATE_SCHEMA_VERSION,
327            "node_id": node_id,
328            "run_id": run_id,
329            "parent_node_id": null,
330            "kind": "spinoff",
331            "status": "pending",
332            "task": null,
333            "worktree_path": null,
334            "branch": null,
335            "tmux_window": null,
336            "agent_pid": null,
337            "agent_pid_start_time": null,
338            "supervisor_pid": null,
339            "children": [],
340            "started_at": null,
341            "updated_at": "2026-06-12T00:00:00Z",
342            "last_report": null,
343            "last_processed_report_seq_by_child": {}
344        })
345    }
346
347    fn manifest_json(run_id: &str) -> Value {
348        json!({
349            "schema_version": STATE_SCHEMA_VERSION,
350            "run_id": run_id,
351            "kind": "spinoff",
352            "lifecycle": "autonomous",
353            "title": "fixture",
354            "status": "pending",
355            "created_at": "2026-06-12T00:00:00Z",
356            "updated_at": "2026-06-12T00:00:00Z",
357            "source_repo": null,
358            "source_branch": null,
359            "worktree_root": null,
360            "node_count": 0,
361            "parent_run_id": null,
362            "parent_node_id": null
363        })
364    }
365
366    fn write_raw(path: &Path, v: &Value) {
367        std::fs::write(path, serde_json::to_vec(v).unwrap()).unwrap();
368    }
369
370    // --- derived counters --------------------------------------------------
371
372    #[test]
373    fn derive_counters_counts_projection_state_and_ignores_junk() {
374        let (_tmp, paths) = setup();
375        // Two nodes.
376        write_raw(
377            &paths.node(&NodeId::parse_str("n-0001").unwrap()),
378            &node_json("n-0001", RUN),
379        );
380        write_raw(
381            &paths.node(&NodeId::parse_str("n-0002").unwrap()),
382            &node_json("n-0002", RUN),
383        );
384
385        // Junk that must be ignored: a non-`json` file, a `json` file whose stem
386        // is not a valid id, and a hidden tempfile mimicking an in-flight atomic
387        // write.
388        std::fs::write(paths.nodes_dir().join("README.txt"), b"x").unwrap();
389        std::fs::write(paths.nodes_dir().join("not-an-id.json"), b"{}").unwrap();
390        std::fs::write(paths.nodes_dir().join(".n-0003.json.tmp.123.0"), b"{}").unwrap();
391
392        let c = derive_counters(&paths).unwrap();
393        assert_eq!(c.node_count, 2);
394    }
395
396    #[test]
397    fn derive_counters_missing_dirs_are_zero() {
398        let tmp = TempDir::new().unwrap();
399        let dir = tmp.path().join("run");
400        std::fs::create_dir_all(&dir).unwrap();
401        let paths = RunPaths::new(&dir, RUN).unwrap();
402        // No nodes/ subdirectory exists.
403        let c = derive_counters(&paths).unwrap();
404        assert_eq!(c.node_count, 0);
405    }
406
407    // --- read side: body id must equal the requested filename key ---------
408
409    #[test]
410    fn read_node_rejects_body_id_mismatch() {
411        let (_tmp, paths) = setup();
412        let requested = NodeId::parse_str("n-0001").unwrap();
413        // A perfectly valid n-0002 projection, mis-filed at n-0001's path.
414        let p = paths.node(&requested);
415        write_raw(&p, &node_json("n-0002", RUN));
416        assert!(matches!(
417            read_node(&paths, &requested),
418            Err(Error::CorruptProjection { kind: "node", path, expected_id, body_id })
419                if path == p && expected_id == "n-0001" && body_id == "n-0002"
420        ));
421    }
422
423    #[test]
424    fn read_node_opt_rejects_body_id_mismatch() {
425        // The `_opt` variant is what the reducer and CLI actually call, so the
426        // guard must fire there too — a mismatch is an error, not `None`.
427        let (_tmp, paths) = setup();
428        let requested = NodeId::parse_str("n-0001").unwrap();
429        write_raw(&paths.node(&requested), &node_json("n-0002", RUN));
430        assert!(matches!(
431            read_node_opt(&paths, &requested),
432            Err(Error::CorruptProjection { kind: "node", .. })
433        ));
434    }
435
436    #[test]
437    fn read_node_rejects_foreign_run_id() {
438        // Correct filename key, but the body belongs to another run — the file
439        // was copied/restored from a foreign run directory.
440        let (_tmp, paths) = setup();
441        let requested = NodeId::parse_str("n-0001").unwrap();
442        write_raw(&paths.node(&requested), &node_json("n-0001", FOREIGN_RUN));
443        assert!(matches!(
444            read_node(&paths, &requested),
445            Err(Error::CorruptProjection { kind: "node_run_id", expected_id, body_id, .. })
446                if expected_id == RUN && body_id == FOREIGN_RUN
447        ));
448    }
449
450    #[test]
451    fn read_node_accepts_matching_key() {
452        // Guard against a false positive: the well-filed case still reads.
453        let (_tmp, paths) = setup();
454        let requested = NodeId::parse_str("n-0001").unwrap();
455        write_raw(&paths.node(&requested), &node_json("n-0001", RUN));
456        let n = read_node(&paths, &requested).unwrap();
457        assert_eq!(n.node_id.as_str(), "n-0001");
458    }
459
460    #[test]
461    fn read_manifest_rejects_foreign_run_id() {
462        // The manifest is keyed by its directory, so a foreign-run manifest
463        // restored into this run's dir is the same class of corruption.
464        let (_tmp, paths) = setup();
465        write_raw(&paths.manifest(), &manifest_json(FOREIGN_RUN));
466        assert!(matches!(
467            read_manifest(&paths),
468            Err(Error::CorruptProjection { kind: "manifest_run_id", expected_id, body_id, .. })
469                if expected_id == RUN && body_id == FOREIGN_RUN
470        ));
471        // `_opt` must reject it too, not paper over it as `None`.
472        assert!(matches!(
473            read_manifest_opt(&paths),
474            Err(Error::CorruptProjection {
475                kind: "manifest_run_id",
476                ..
477            })
478        ));
479    }
480
481    #[test]
482    fn read_manifest_accepts_matching_run_id() {
483        let (_tmp, paths) = setup();
484        write_raw(&paths.manifest(), &manifest_json(RUN));
485        assert_eq!(read_manifest(&paths).unwrap().run_id.as_str(), RUN);
486    }
487
488    // --- write side: object run_id must equal the run's RunPaths.run_id ----
489
490    #[test]
491    fn write_node_rejects_foreign_run_id() {
492        let (_tmp, paths) = setup();
493        let n: Node = serde_json::from_value(node_json("n-0001", FOREIGN_RUN)).unwrap();
494        assert!(matches!(
495            write_node(&paths, &n),
496            Err(Error::CorruptProjection { kind: "node_run_id", expected_id, body_id, .. })
497                if expected_id == RUN && body_id == FOREIGN_RUN
498        ));
499        // The forged write never touched disk.
500        assert!(!paths.node(&n.node_id).exists());
501    }
502
503    #[test]
504    fn write_node_accepts_matching_run_id() {
505        let (_tmp, paths) = setup();
506        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
507        write_node(&paths, &n).unwrap();
508        assert!(paths.node(&n.node_id).exists());
509    }
510
511    #[test]
512    fn write_manifest_rejects_foreign_run_id() {
513        let (_tmp, paths) = setup();
514        let m: Manifest = serde_json::from_value(manifest_json(FOREIGN_RUN)).unwrap();
515        assert!(matches!(
516            write_manifest(&paths, &m),
517            Err(Error::CorruptProjection { kind: "manifest_run_id", expected_id, body_id, .. })
518                if expected_id == RUN && body_id == FOREIGN_RUN
519        ));
520        assert!(!paths.manifest().exists());
521    }
522
523    #[test]
524    fn write_manifest_accepts_matching_run_id() {
525        let (_tmp, paths) = setup();
526        let m: Manifest = serde_json::from_value(manifest_json(RUN)).unwrap();
527        write_manifest(&paths, &m).unwrap();
528        assert!(paths.manifest().exists());
529    }
530
531    // --- symlink containment: a replaced subdir or file is refused ---------
532    //
533    // Each test stores a *valid* projection behind the symlink so the rejection
534    // can only come from the symlink guard, never from a parse/key/run-id check
535    // downstream.
536
537    #[cfg(unix)]
538    #[test]
539    fn read_node_rejects_symlinked_nodes_dir() {
540        use std::os::unix::fs::symlink;
541        let (tmp, paths) = setup();
542        let outside = tmp.path().join("outside");
543        std::fs::create_dir_all(&outside).unwrap();
544        std::fs::remove_dir(paths.nodes_dir()).unwrap();
545        symlink(&outside, paths.nodes_dir()).unwrap();
546        let id = NodeId::parse_str("n-0001").unwrap();
547        write_raw(&outside.join("n-0001.json"), &node_json("n-0001", RUN));
548        assert!(matches!(
549            read_node(&paths, &id),
550            Err(Error::SymlinkSubdir { name: "nodes", .. })
551        ));
552    }
553
554    #[cfg(unix)]
555    #[test]
556    fn read_node_rejects_symlinked_node_file() {
557        use std::os::unix::fs::symlink;
558        let (tmp, paths) = setup();
559        let id = NodeId::parse_str("n-0001").unwrap();
560        let target = tmp.path().join("evil-node.json");
561        write_raw(&target, &node_json("n-0001", RUN));
562        symlink(&target, paths.node(&id)).unwrap();
563        assert!(matches!(
564            read_node(&paths, &id),
565            Err(Error::SymlinkStateFile { name: "node", .. })
566        ));
567    }
568
569    /// The `O_NOFOLLOW` backstop, isolated from the `symlink_metadata` check
570    /// the `checked_*` resolvers run first: calling the leaf reader directly on
571    /// a symlinked projection must fail the *open* with `ELOOP` rather than
572    /// following it. This is the half of the TOCTOU window that survives a leaf
573    /// swapped *after* the `symlink_metadata` check but *before* the open.
574    #[cfg(unix)]
575    #[test]
576    fn read_json_refuses_to_follow_a_symlinked_projection() {
577        use std::os::unix::fs::symlink;
578        let (tmp, _paths) = setup();
579        let target = tmp.path().join("evil-node.json");
580        write_raw(&target, &node_json("n-0001", RUN));
581        let link = tmp.path().join("link-node.json");
582        symlink(&target, &link).unwrap();
583        let err = read_json::<Node>(&link).expect_err("must refuse a symlinked projection");
584        match err {
585            Error::Io { source, .. } => assert_eq!(
586                source.raw_os_error(),
587                Some(libc::ELOOP),
588                "O_NOFOLLOW open of a symlink must report ELOOP, got {source:?}"
589            ),
590            other => panic!("expected Error::Io(ELOOP), got {other:?}"),
591        }
592    }
593
594    #[cfg(unix)]
595    #[test]
596    fn write_node_rejects_symlinked_nodes_dir() {
597        // The write side is guarded too: a symlinked subdir would otherwise
598        // land the atomic temp+rename outside the run tree.
599        use std::os::unix::fs::symlink;
600        let (tmp, paths) = setup();
601        let outside = tmp.path().join("outside");
602        std::fs::create_dir_all(&outside).unwrap();
603        std::fs::remove_dir(paths.nodes_dir()).unwrap();
604        symlink(&outside, paths.nodes_dir()).unwrap();
605        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
606        assert!(matches!(
607            write_node(&paths, &n),
608            Err(Error::SymlinkSubdir { name: "nodes", .. })
609        ));
610        // The forged write never reached the symlink target.
611        assert!(!outside.join("n-0001.json").exists());
612    }
613
614    #[cfg(unix)]
615    #[test]
616    fn read_node_re_guards_a_run_root_swapped_after_construction() {
617        // The access-time guard must catch a root that becomes a symlink AFTER
618        // the (now-checked) constructor ran — the long-lived-handle case. Build
619        // a clean RunPaths, then swap its root dir for a symlink to an outside
620        // dir that holds an otherwise-valid node, and confirm the read refuses
621        // to follow it.
622        use std::os::unix::fs::symlink;
623        let tmp = TempDir::new().unwrap();
624        let root = tmp.path().join("run");
625        let paths = RunPaths::new(&root, RUN).unwrap();
626        let id = NodeId::parse_str("n-0001").unwrap();
627        // Outside target with a real nodes/ and a valid node behind it.
628        let outside = tmp.path().join("outside");
629        std::fs::create_dir_all(outside.join("nodes")).unwrap();
630        write_raw(
631            &outside.join("nodes/n-0001.json"),
632            &node_json("n-0001", RUN),
633        );
634        // Swap the real run dir for a symlink to `outside`.
635        std::fs::remove_dir_all(&root).ok();
636        std::fs::create_dir_all(&root).unwrap();
637        std::fs::remove_dir(&root).unwrap();
638        symlink(&outside, &root).unwrap();
639        assert!(matches!(
640            read_node(&paths, &id),
641            Err(Error::SymlinkRunDir { .. })
642        ));
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn from_validated_rejects_a_symlinked_run_root_at_construction() {
648        use std::os::unix::fs::symlink;
649        let tmp = TempDir::new().unwrap();
650        let real = tmp.path().join("real");
651        std::fs::create_dir_all(&real).unwrap();
652        let link = tmp.path().join("link");
653        symlink(&real, &link).unwrap();
654        assert!(matches!(
655            RunPaths::from_validated(link, RunId::parse_str(RUN).unwrap()),
656            Err(Error::SymlinkRunDir { .. })
657        ));
658    }
659
660    // --- manifest + write-side file symlink coverage ----------------------
661
662    #[cfg(unix)]
663    #[test]
664    fn read_manifest_rejects_symlinked_manifest_file() {
665        use std::os::unix::fs::symlink;
666        let (tmp, paths) = setup();
667        let target = tmp.path().join("evil-manifest.json");
668        write_raw(&target, &manifest_json(RUN));
669        symlink(&target, paths.manifest()).unwrap();
670        assert!(matches!(
671            read_manifest(&paths),
672            Err(Error::SymlinkStateFile {
673                name: "manifest",
674                ..
675            })
676        ));
677    }
678
679    #[cfg(unix)]
680    #[test]
681    fn write_node_rejects_symlinked_node_file() {
682        // Write side: a symlinked target file is refused before the atomic
683        // temp+rename runs, so the forged write never reaches the link target.
684        use std::os::unix::fs::symlink;
685        let (tmp, paths) = setup();
686        let id = NodeId::parse_str("n-0001").unwrap();
687        let target = tmp.path().join("evil-node.json");
688        symlink(&target, paths.node(&id)).unwrap();
689        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
690        assert!(matches!(
691            write_node(&paths, &n),
692            Err(Error::SymlinkStateFile { name: "node", .. })
693        ));
694        assert!(!target.exists());
695    }
696
697    #[cfg(unix)]
698    #[test]
699    fn read_node_rejects_dangling_symlinked_file() {
700        // A symlink whose target does not exist is still a symlink — it must be
701        // rejected as corruption, not treated as an absent file (`None`).
702        use std::os::unix::fs::symlink;
703        let (tmp, paths) = setup();
704        let id = NodeId::parse_str("n-0001").unwrap();
705        symlink(tmp.path().join("does-not-exist.json"), paths.node(&id)).unwrap();
706        assert!(matches!(
707            read_node_opt(&paths, &id),
708            Err(Error::SymlinkStateFile { name: "node", .. })
709        ));
710    }
711}