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/`,
6//! `discussions/`, or `spinoffs/` projection 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::{
19    Discussion, DiscussionId, DiscussionStatus, Manifest, Node, NodeId, ProposalId, RunId,
20    SpinoffProposal, SpinoffStatus, SUPPORTED_STATE_SCHEMAS,
21};
22
23/// Resolve a projection file path while rejecting a symlinked run root, the
24/// symlinked subdir, or a symlinked file before the caller opens it — so a
25/// tampered run-tree component cannot redirect a read or write outside the run
26/// directory. `dir_name` names the containing subdir (for [`Error::SymlinkSubdir`])
27/// and `file_kind` names the projection type (for [`Error::SymlinkStateFile`]);
28/// both checks run after the run root is guarded. Best-effort containment with a
29/// check-then-open TOCTOU gap — see [`reject_symlink`].
30fn checked_file(
31    paths: &RunPaths,
32    subdir: PathBuf,
33    dir_name: &'static str,
34    file: PathBuf,
35    file_kind: &'static str,
36) -> Result<PathBuf> {
37    paths.guard_root()?;
38    reject_symlink(&subdir, || Error::SymlinkSubdir {
39        name: dir_name,
40        path: subdir.clone(),
41    })?;
42    reject_symlink(&file, || Error::SymlinkStateFile {
43        name: file_kind,
44        path: file.clone(),
45    })?;
46    Ok(file)
47}
48
49/// `manifest.json` path, guarding the run root and the manifest file itself.
50fn checked_manifest(paths: &RunPaths) -> Result<PathBuf> {
51    paths.guard_root()?;
52    let p = paths.manifest();
53    reject_symlink(&p, || Error::SymlinkStateFile {
54        name: "manifest",
55        path: p.clone(),
56    })?;
57    Ok(p)
58}
59
60/// `nodes/<id>.json` path with run-root, `nodes/`, and file symlink guards.
61fn checked_node(paths: &RunPaths, id: &NodeId) -> Result<PathBuf> {
62    checked_file(paths, paths.nodes_dir(), "nodes", paths.node(id), "node")
63}
64
65/// `discussions/<id>.json` path with run-root, `discussions/`, and file guards.
66fn checked_discussion(paths: &RunPaths, id: &DiscussionId) -> Result<PathBuf> {
67    checked_file(
68        paths,
69        paths.discussions_dir(),
70        "discussions",
71        paths.discussion(id),
72        "discussion",
73    )
74}
75
76/// `spinoffs/<id>.json` path with run-root, `spinoffs/`, and file guards.
77fn checked_spinoff(paths: &RunPaths, id: &ProposalId) -> Result<PathBuf> {
78    checked_file(
79        paths,
80        paths.spinoffs_dir(),
81        "spinoffs",
82        paths.spinoff(id),
83        "spinoff",
84    )
85}
86
87/// Read `path` into bytes with `O_NOFOLLOW`: a projection file replaced by a
88/// symlink fails the open (`ELOOP`) rather than redirecting the read. This is
89/// the file-level TOCTOU backstop to the `reject_symlink` check the `checked_*`
90/// resolvers run before calling in here — projection *writes* go via temp-file +
91/// rename (never opening the leaf), so the read is the projection's only
92/// follow-through-a-symlink surface. See [`crate::paths::nofollow`].
93fn read_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
94    use std::io::Read;
95    let mut opts = std::fs::OpenOptions::new();
96    opts.read(true);
97    crate::paths::nofollow(&mut opts);
98    let mut f = opts.open(path)?;
99    let mut buf = Vec::new();
100    f.read_to_end(&mut buf)?;
101    Ok(buf)
102}
103
104fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
105    let bytes = read_nofollow(path).map_err(|e| Error::io(path, e))?;
106    serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
107}
108
109fn read_json_opt<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
110    match read_nofollow(path) {
111        Ok(bytes) => Ok(Some(
112            serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))?,
113        )),
114        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
115        Err(e) => Err(Error::io(path, e)),
116    }
117}
118
119fn check_schema(path: &Path, found: u32) -> Result<()> {
120    if SUPPORTED_STATE_SCHEMAS.contains(&found) {
121        Ok(())
122    } else {
123        Err(Error::UnsupportedSchemaVersion {
124            path: path.to_path_buf(),
125            found,
126            supported: SUPPORTED_STATE_SCHEMAS.to_vec(),
127        })
128    }
129}
130
131/// Reject a projection whose body id (`body`) does not equal the filename key
132/// it was read under (`expected`). Both are already-validated id newtypes — a
133/// well-formed `nodes/n-0002.json` mis-filed as `nodes/n-0001.json` parses
134/// cleanly yet describes a different object, so handing it back would let a
135/// later keyed write clobber a third file. `kind` names the projection type so
136/// a caller can branch on the [`Error::CorruptProjection`] it produces; `path`
137/// localizes the offending file.
138fn check_key(path: &Path, kind: &'static str, expected: &str, body: &str) -> Result<()> {
139    if expected == body {
140        Ok(())
141    } else {
142        Err(Error::CorruptProjection {
143            kind,
144            path: path.to_path_buf(),
145            expected_id: expected.to_string(),
146            body_id: body.to_string(),
147        })
148    }
149}
150
151/// Reject a projection whose object `run_id` (`body`) does not equal the run
152/// the [`RunPaths`] is anchored on (`expected`). Fires on both sides: every
153/// `read_*` rejects a file that belongs to a foreign run before handing it
154/// back, and every `write_*` refuses to stamp a foreign run's id into this
155/// run's directory — feasible now that `RunPaths` carries a typed
156/// [`crate::RunId`]. Takes `&RunId` (not `&str`) so a caller cannot transpose
157/// the arguments or pass an unrelated id. `kind` is the run-id discriminator
158/// (`"node_run_id"`, etc.); `path` localizes the file.
159fn check_run_id(path: &Path, kind: &'static str, expected: &RunId, body: &RunId) -> Result<()> {
160    if expected == body {
161        Ok(())
162    } else {
163        Err(Error::CorruptProjection {
164            kind,
165            path: path.to_path_buf(),
166            expected_id: expected.to_string(),
167            body_id: body.to_string(),
168        })
169    }
170}
171
172/// Read and schema-validate the run manifest. Errors if it is missing.
173pub fn read_manifest(paths: &RunPaths) -> Result<Manifest> {
174    let p = checked_manifest(paths)?;
175    let m: Manifest = read_json(&p)?;
176    check_schema(&p, m.schema_version)?;
177    check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
178    Ok(m)
179}
180
181/// Read and schema-validate the run manifest, returning `None` if absent.
182///
183/// A present manifest whose `run_id` belongs to a foreign run is an error, not
184/// `None`: `_opt` means "missing file is fine", not "corrupt file is absent".
185pub fn read_manifest_opt(paths: &RunPaths) -> Result<Option<Manifest>> {
186    let p = checked_manifest(paths)?;
187    match read_json_opt::<Manifest>(&p)? {
188        Some(m) => {
189            check_schema(&p, m.schema_version)?;
190            check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
191            Ok(Some(m))
192        }
193        None => Ok(None),
194    }
195}
196
197/// Atomically write the run manifest (temp file + rename).
198///
199/// `pub(crate)`: projection writes belong to the reducer. External callers
200/// mutate state through [`crate::events::append_and_apply_event`] so a write
201/// can never bypass the event log or the run's `flock`.
202pub(crate) fn write_manifest(paths: &RunPaths, m: &Manifest) -> Result<()> {
203    let p = checked_manifest(paths)?;
204    check_run_id(&p, "manifest_run_id", &paths.run_id, &m.run_id)?;
205    write_json_atomic(&p, m)
206}
207
208/// Read and schema-validate one node. Errors if it is missing.
209pub fn read_node(paths: &RunPaths, node_id: &NodeId) -> Result<Node> {
210    let p = checked_node(paths, node_id)?;
211    let n: Node = read_json(&p)?;
212    check_schema(&p, n.schema_version)?;
213    check_key(&p, "node", node_id.as_str(), n.node_id.as_str())?;
214    check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
215    Ok(n)
216}
217
218/// Read and schema-validate one node, returning `None` if absent.
219///
220/// A present node whose body id or `run_id` does not match where it lives is an
221/// error, not `None`: `_opt` covers a missing file, not a corrupt one.
222pub fn read_node_opt(paths: &RunPaths, node_id: &NodeId) -> Result<Option<Node>> {
223    let p = checked_node(paths, node_id)?;
224    match read_json_opt::<Node>(&p)? {
225        Some(n) => {
226            check_schema(&p, n.schema_version)?;
227            check_key(&p, "node", node_id.as_str(), n.node_id.as_str())?;
228            check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
229            Ok(Some(n))
230        }
231        None => Ok(None),
232    }
233}
234
235/// Atomically write a node's projection file, keyed by its `node_id`.
236///
237/// Stays `pub` (unlike the other `write_*` helpers) as the sanctioned
238/// lock-held composition path for the supervisor batch: the supervisor
239/// mirrors per-child report cursors and a child's `supervisor_pid` directly
240/// onto the node projection while holding the run's `flock` — fields no
241/// event/reducer path manages. Pair it with [`crate::RunLock`].
242pub fn write_node(paths: &RunPaths, n: &Node) -> Result<()> {
243    let p = checked_node(paths, &n.node_id)?;
244    check_run_id(&p, "node_run_id", &paths.run_id, &n.run_id)?;
245    write_json_atomic(&p, n)
246}
247
248/// Read and schema-validate one discussion. Errors if it is missing.
249pub fn read_discussion(paths: &RunPaths, id: &DiscussionId) -> Result<Discussion> {
250    let p = checked_discussion(paths, id)?;
251    let d: Discussion = read_json(&p)?;
252    check_schema(&p, d.schema_version)?;
253    check_key(&p, "discussion", id.as_str(), d.discussion_id.as_str())?;
254    check_run_id(&p, "discussion_run_id", &paths.run_id, &d.run_id)?;
255    Ok(d)
256}
257
258/// Read and schema-validate one discussion, returning `None` if absent.
259///
260/// A present discussion whose body id or `run_id` does not match where it lives
261/// is an error, not `None`: `_opt` covers a missing file, not a corrupt one.
262pub fn read_discussion_opt(paths: &RunPaths, id: &DiscussionId) -> Result<Option<Discussion>> {
263    let p = checked_discussion(paths, id)?;
264    match read_json_opt::<Discussion>(&p)? {
265        Some(d) => {
266            check_schema(&p, d.schema_version)?;
267            check_key(&p, "discussion", id.as_str(), d.discussion_id.as_str())?;
268            check_run_id(&p, "discussion_run_id", &paths.run_id, &d.run_id)?;
269            Ok(Some(d))
270        }
271        None => Ok(None),
272    }
273}
274
275/// Atomically write a discussion file, keyed by its `discussion_id`.
276///
277/// `pub(crate)`: see [`write_manifest`].
278pub(crate) fn write_discussion(paths: &RunPaths, d: &Discussion) -> Result<()> {
279    let p = checked_discussion(paths, &d.discussion_id)?;
280    check_run_id(&p, "discussion_run_id", &paths.run_id, &d.run_id)?;
281    write_json_atomic(&p, d)
282}
283
284/// Read and schema-validate one spin-off proposal. Errors if it is missing.
285pub fn read_spinoff(paths: &RunPaths, id: &ProposalId) -> Result<SpinoffProposal> {
286    let p = checked_spinoff(paths, id)?;
287    let s: SpinoffProposal = read_json(&p)?;
288    check_schema(&p, s.schema_version)?;
289    check_key(&p, "spinoff", id.as_str(), s.proposal_id.as_str())?;
290    check_run_id(&p, "spinoff_run_id", &paths.run_id, &s.run_id)?;
291    Ok(s)
292}
293
294/// Read and schema-validate one spin-off proposal, returning `None` if absent.
295///
296/// A present proposal whose body id or `run_id` does not match where it lives
297/// is an error, not `None`: `_opt` covers a missing file, not a corrupt one.
298pub fn read_spinoff_opt(paths: &RunPaths, id: &ProposalId) -> Result<Option<SpinoffProposal>> {
299    let p = checked_spinoff(paths, id)?;
300    match read_json_opt::<SpinoffProposal>(&p)? {
301        Some(s) => {
302            check_schema(&p, s.schema_version)?;
303            check_key(&p, "spinoff", id.as_str(), s.proposal_id.as_str())?;
304            check_run_id(&p, "spinoff_run_id", &paths.run_id, &s.run_id)?;
305            Ok(Some(s))
306        }
307        None => Ok(None),
308    }
309}
310
311/// Atomically write a spin-off proposal file, keyed by its `proposal_id`.
312///
313/// `pub(crate)`: see [`write_manifest`].
314pub(crate) fn write_spinoff(paths: &RunPaths, s: &SpinoffProposal) -> Result<()> {
315    let p = checked_spinoff(paths, &s.proposal_id)?;
316    check_run_id(&p, "spinoff_run_id", &paths.run_id, &s.run_id)?;
317    write_json_atomic(&p, s)
318}
319
320/// The manifest's denormalized counters, recomputed from projection state.
321///
322/// Returned by [`derive_counters`] as a single snapshot of the `nodes/`,
323/// `discussions/`, and `spinoffs/` directories.
324pub(crate) struct DerivedCounters {
325    /// Number of node projection files.
326    pub node_count: u32,
327    /// Number of discussions whose status is [`DiscussionStatus::Open`].
328    pub open_discussions: u32,
329    /// Number of spin-off proposals whose status is [`SpinoffStatus::Proposed`].
330    pub pending_spinoffs: u32,
331}
332
333/// Recompute the manifest's denormalized counters directly from the projection
334/// directories, so they are a pure function of projection state rather than an
335/// incrementally-maintained delta.
336///
337/// This is the heart of the counter-desync fix (issue
338/// `manifest-counter-desync`): [`crate::events::advance_applied_seq`] calls this
339/// whenever it advances the `applied_seq` watermark, so the counters persisted
340/// alongside the watermark always equal a fresh count of the projection state
341/// as it stands *after* an event's projection writes are committed. The old
342/// incremental path could strand a stale counter forever — if a projection
343/// write landed but the follow-on `manifest.json` write did not, the crash-
344/// replay re-folded the event, hit the reducer's "already exists / already
345/// terminal" idempotency guard, and skipped the counter mutation that never
346/// happened. Deriving the counts removes the delta entirely: drift is
347/// impossible because nothing is ever incremented.
348///
349/// Counting is best-effort under corruption: a directory that does not exist
350/// counts as empty, and a projection file that fails to read or parse is
351/// skipped rather than bricking every future append (`doctor` surfaces such
352/// anomalies). Only regular `*.json` files whose stem is a well-formed
353/// projection id are counted, so an in-flight atomic write (a hidden
354/// `.<name>.tmp.<pid>.<n>` tempfile) is never miscounted.
355pub(crate) fn derive_counters(paths: &RunPaths) -> Result<DerivedCounters> {
356    Ok(DerivedCounters {
357        node_count: count_node_files(paths)?,
358        open_discussions: count_open_discussions(paths)?,
359        pending_spinoffs: count_pending_spinoffs(paths)?,
360    })
361}
362
363/// Open `dir` for a counting walk: a missing directory yields `None` (count 0);
364/// a real `read_dir` failure propagates. Pairs with [`projection_id_stem`].
365fn open_projection_dir(dir: &Path) -> Result<Option<std::fs::ReadDir>> {
366    match std::fs::read_dir(dir) {
367        Ok(e) => Ok(Some(e)),
368        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
369        Err(e) => Err(Error::io(dir, e)),
370    }
371}
372
373/// The id-stem of a projection slot: `Some(stem)` for a regular `*.json` file,
374/// `None` for directories, non-`json` entries, and the hidden tempfiles atomic
375/// writes leave mid-rename. The caller decides whether `stem` is a valid id.
376fn projection_id_stem(ent: &std::fs::DirEntry) -> Option<String> {
377    if !ent.file_type().is_ok_and(|t| t.is_file()) {
378        return None;
379    }
380    let path = ent.path();
381    if path.extension().and_then(|s| s.to_str()) != Some("json") {
382        return None;
383    }
384    path.file_stem()
385        .and_then(|s| s.to_str())
386        .map(str::to_string)
387}
388
389/// Count node projection files: every regular `nodes/<node-id>.json` whose stem
390/// is a well-formed [`NodeId`]. A node file's mere existence means the node was
391/// created, so this needs no content read.
392fn count_node_files(paths: &RunPaths) -> Result<u32> {
393    let dir = paths.nodes_dir();
394    let Some(entries) = open_projection_dir(&dir)? else {
395        return Ok(0);
396    };
397    let mut n: u32 = 0;
398    for ent in entries {
399        let ent = ent.map_err(|e| Error::io(&dir, e))?;
400        if let Some(stem) = projection_id_stem(&ent) {
401            if NodeId::parse_str(&stem).is_ok() {
402                n = n.saturating_add(1);
403            }
404        }
405    }
406    Ok(n)
407}
408
409/// Count discussions whose status is [`DiscussionStatus::Open`]. Status lives in
410/// the file body, so each candidate is read; an unreadable/corrupt file is
411/// skipped (best-effort — see [`derive_counters`]).
412fn count_open_discussions(paths: &RunPaths) -> Result<u32> {
413    let dir = paths.discussions_dir();
414    let Some(entries) = open_projection_dir(&dir)? else {
415        return Ok(0);
416    };
417    let mut n: u32 = 0;
418    for ent in entries {
419        let ent = ent.map_err(|e| Error::io(&dir, e))?;
420        let Some(stem) = projection_id_stem(&ent) else {
421            continue;
422        };
423        let Ok(id) = DiscussionId::parse_str(&stem) else {
424            continue;
425        };
426        if let Ok(Some(d)) = read_discussion_opt(paths, &id) {
427            if matches!(d.status, DiscussionStatus::Open) {
428                n = n.saturating_add(1);
429            }
430        }
431    }
432    Ok(n)
433}
434
435/// Count spin-off proposals whose status is [`SpinoffStatus::Proposed`]. See
436/// [`count_open_discussions`] for the read/skip contract.
437fn count_pending_spinoffs(paths: &RunPaths) -> Result<u32> {
438    let dir = paths.spinoffs_dir();
439    let Some(entries) = open_projection_dir(&dir)? else {
440        return Ok(0);
441    };
442    let mut n: u32 = 0;
443    for ent in entries {
444        let ent = ent.map_err(|e| Error::io(&dir, e))?;
445        let Some(stem) = projection_id_stem(&ent) else {
446            continue;
447        };
448        let Ok(id) = ProposalId::parse_str(&stem) else {
449            continue;
450        };
451        if let Ok(Some(s)) = read_spinoff_opt(paths, &id) {
452            if matches!(s.status, SpinoffStatus::Proposed) {
453                n = n.saturating_add(1);
454            }
455        }
456    }
457    Ok(n)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::schema::STATE_SCHEMA_VERSION;
464    use serde_json::{json, Value};
465    use tempfile::TempDir;
466
467    /// The run this run-directory belongs to, and a *different* well-formed run
468    /// id used to forge the cross-run mismatch the write guards reject.
469    const RUN: &str = "01jxsnap000000000000000000";
470    const FOREIGN_RUN: &str = "02jxsnap000000000000000000";
471
472    /// A run dir under a fresh tempdir, with the projection subdirectories
473    /// created so a hand-written file can be dropped at any key.
474    fn setup() -> (TempDir, RunPaths) {
475        let tmp = TempDir::new().unwrap();
476        let dir = tmp.path().join("run");
477        let paths = RunPaths::new(&dir, RUN).unwrap();
478        std::fs::create_dir_all(paths.nodes_dir()).unwrap();
479        std::fs::create_dir_all(paths.discussions_dir()).unwrap();
480        std::fs::create_dir_all(paths.spinoffs_dir()).unwrap();
481        (tmp, paths)
482    }
483
484    fn node_json(node_id: &str, run_id: &str) -> Value {
485        json!({
486            "schema_version": STATE_SCHEMA_VERSION,
487            "node_id": node_id,
488            "run_id": run_id,
489            "parent_node_id": null,
490            "kind": "spinoff",
491            "status": "pending",
492            "task": null,
493            "worktree_path": null,
494            "branch": null,
495            "tmux_window": null,
496            "agent_pid": null,
497            "agent_pid_start_time": null,
498            "supervisor_pid": null,
499            "children": [],
500            "started_at": null,
501            "updated_at": "2026-06-12T00:00:00Z",
502            "last_report": null,
503            "last_processed_report_seq_by_child": {}
504        })
505    }
506
507    fn discussion_json(discussion_id: &str, run_id: &str) -> Value {
508        json!({
509            "schema_version": STATE_SCHEMA_VERSION,
510            "discussion_id": discussion_id,
511            "run_id": run_id,
512            "node_id": "n-0001",
513            "opened_at": "2026-06-12T00:00:00Z",
514            "severity": "normal",
515            "topic": "fixture",
516            "context": null,
517            "options": [],
518            "status": "open",
519            "resolution": null,
520            "note": null,
521            "resolved_at": null
522        })
523    }
524
525    fn spinoff_json(proposal_id: &str, run_id: &str) -> Value {
526        json!({
527            "schema_version": STATE_SCHEMA_VERSION,
528            "proposal_id": proposal_id,
529            "run_id": run_id,
530            "node_id": "n-0001",
531            "proposed_at": "2026-06-12T00:00:00Z",
532            "proposed_title": "fixture",
533            "proposed_kind": "spinoff",
534            "rationale": null,
535            "status": "proposed",
536            "accepted_as_issue_slug": null,
537            "rejected_reason": null,
538            "resolved_at": null
539        })
540    }
541
542    fn manifest_json(run_id: &str) -> Value {
543        json!({
544            "schema_version": STATE_SCHEMA_VERSION,
545            "run_id": run_id,
546            "kind": "spinoff",
547            "lifecycle": "autonomous",
548            "title": "fixture",
549            "status": "pending",
550            "created_at": "2026-06-12T00:00:00Z",
551            "updated_at": "2026-06-12T00:00:00Z",
552            "source_repo": null,
553            "source_branch": null,
554            "worktree_root": null,
555            "node_count": 0,
556            "open_discussions": 0,
557            "pending_spinoffs": 0,
558            "parent_run_id": null,
559            "parent_node_id": null
560        })
561    }
562
563    fn write_raw(path: &Path, v: &Value) {
564        std::fs::write(path, serde_json::to_vec(v).unwrap()).unwrap();
565    }
566
567    // Two valid 26-char Crockford ULID bodies differing in the last char —
568    // used as the "requested key" vs "mis-filed body" pair for discussions and
569    // spinoffs (the prefix is supplied per type).
570    const ULID_A: &str = "01arz3ndektsv4rrffq69g5fav";
571    const ULID_B: &str = "01arz3ndektsv4rrffq69g5faw";
572
573    // --- derived counters --------------------------------------------------
574
575    #[test]
576    fn derive_counters_counts_projection_state_and_ignores_junk() {
577        let (_tmp, paths) = setup();
578        // Two nodes.
579        write_raw(
580            &paths.node(&NodeId::parse_str("n-0001").unwrap()),
581            &node_json("n-0001", RUN),
582        );
583        write_raw(
584            &paths.node(&NodeId::parse_str("n-0002").unwrap()),
585            &node_json("n-0002", RUN),
586        );
587        // Two discussions: only the open one counts toward `open_discussions`.
588        let d_open = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
589        let d_resolved = DiscussionId::parse_str(&format!("d-{ULID_B}")).unwrap();
590        write_raw(
591            &paths.discussion(&d_open),
592            &discussion_json(d_open.as_str(), RUN),
593        );
594        let mut dr = discussion_json(d_resolved.as_str(), RUN);
595        dr["status"] = json!("resolved");
596        write_raw(&paths.discussion(&d_resolved), &dr);
597        // Two spinoffs: only the proposed one is pending.
598        let s_pending = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
599        let s_done = ProposalId::parse_str(&format!("s-{ULID_B}")).unwrap();
600        write_raw(
601            &paths.spinoff(&s_pending),
602            &spinoff_json(s_pending.as_str(), RUN),
603        );
604        let mut sd = spinoff_json(s_done.as_str(), RUN);
605        sd["status"] = json!("approved");
606        write_raw(&paths.spinoff(&s_done), &sd);
607
608        // Junk that must be ignored: a non-`json` file, a `json` file whose stem
609        // is not a valid id, and a hidden tempfile mimicking an in-flight atomic
610        // write.
611        std::fs::write(paths.nodes_dir().join("README.txt"), b"x").unwrap();
612        std::fs::write(paths.nodes_dir().join("not-an-id.json"), b"{}").unwrap();
613        std::fs::write(paths.nodes_dir().join(".n-0003.json.tmp.123.0"), b"{}").unwrap();
614
615        let c = derive_counters(&paths).unwrap();
616        assert_eq!(c.node_count, 2);
617        assert_eq!(c.open_discussions, 1);
618        assert_eq!(c.pending_spinoffs, 1);
619    }
620
621    #[test]
622    fn derive_counters_skips_unreadable_files_rather_than_erroring() {
623        // Best-effort under corruption: a garbage file at a valid id path must
624        // not brick the count (which would brick every future append).
625        let (_tmp, paths) = setup();
626        write_raw(
627            &paths.node(&NodeId::parse_str("n-0001").unwrap()),
628            &node_json("n-0001", RUN),
629        );
630        let d = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
631        std::fs::write(paths.discussion(&d), b"{ not valid json").unwrap();
632
633        let c = derive_counters(&paths).unwrap();
634        assert_eq!(c.node_count, 1);
635        assert_eq!(
636            c.open_discussions, 0,
637            "the unreadable discussion is skipped, not counted, and does not error"
638        );
639    }
640
641    #[test]
642    fn derive_counters_missing_dirs_are_zero() {
643        let tmp = TempDir::new().unwrap();
644        let dir = tmp.path().join("run");
645        std::fs::create_dir_all(&dir).unwrap();
646        let paths = RunPaths::new(&dir, RUN).unwrap();
647        // No nodes/ discussions/ spinoffs/ subdirectories exist.
648        let c = derive_counters(&paths).unwrap();
649        assert_eq!(
650            (c.node_count, c.open_discussions, c.pending_spinoffs),
651            (0, 0, 0)
652        );
653    }
654
655    // --- read side: body id must equal the requested filename key ---------
656
657    #[test]
658    fn read_node_rejects_body_id_mismatch() {
659        let (_tmp, paths) = setup();
660        let requested = NodeId::parse_str("n-0001").unwrap();
661        // A perfectly valid n-0002 projection, mis-filed at n-0001's path.
662        let p = paths.node(&requested);
663        write_raw(&p, &node_json("n-0002", RUN));
664        assert!(matches!(
665            read_node(&paths, &requested),
666            Err(Error::CorruptProjection { kind: "node", path, expected_id, body_id })
667                if path == p && expected_id == "n-0001" && body_id == "n-0002"
668        ));
669    }
670
671    #[test]
672    fn read_node_opt_rejects_body_id_mismatch() {
673        // The `_opt` variant is what the reducer and CLI actually call, so the
674        // guard must fire there too — a mismatch is an error, not `None`.
675        let (_tmp, paths) = setup();
676        let requested = NodeId::parse_str("n-0001").unwrap();
677        write_raw(&paths.node(&requested), &node_json("n-0002", RUN));
678        assert!(matches!(
679            read_node_opt(&paths, &requested),
680            Err(Error::CorruptProjection { kind: "node", .. })
681        ));
682    }
683
684    #[test]
685    fn read_node_rejects_foreign_run_id() {
686        // Correct filename key, but the body belongs to another run — the file
687        // was copied/restored from a foreign run directory.
688        let (_tmp, paths) = setup();
689        let requested = NodeId::parse_str("n-0001").unwrap();
690        write_raw(&paths.node(&requested), &node_json("n-0001", FOREIGN_RUN));
691        assert!(matches!(
692            read_node(&paths, &requested),
693            Err(Error::CorruptProjection { kind: "node_run_id", expected_id, body_id, .. })
694                if expected_id == RUN && body_id == FOREIGN_RUN
695        ));
696    }
697
698    #[test]
699    fn read_node_accepts_matching_key() {
700        // Guard against a false positive: the well-filed case still reads.
701        let (_tmp, paths) = setup();
702        let requested = NodeId::parse_str("n-0001").unwrap();
703        write_raw(&paths.node(&requested), &node_json("n-0001", RUN));
704        let n = read_node(&paths, &requested).unwrap();
705        assert_eq!(n.node_id.as_str(), "n-0001");
706    }
707
708    #[test]
709    fn read_discussion_rejects_body_id_mismatch() {
710        let (_tmp, paths) = setup();
711        let requested = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
712        write_raw(
713            &paths.discussion(&requested),
714            &discussion_json(&format!("d-{ULID_B}"), RUN),
715        );
716        assert!(matches!(
717            read_discussion(&paths, &requested),
718            Err(Error::CorruptProjection { kind: "discussion", expected_id, body_id, .. })
719                if expected_id == format!("d-{ULID_A}") && body_id == format!("d-{ULID_B}")
720        ));
721    }
722
723    #[test]
724    fn read_discussion_opt_rejects_body_id_mismatch() {
725        let (_tmp, paths) = setup();
726        let requested = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
727        write_raw(
728            &paths.discussion(&requested),
729            &discussion_json(&format!("d-{ULID_B}"), RUN),
730        );
731        assert!(matches!(
732            read_discussion_opt(&paths, &requested),
733            Err(Error::CorruptProjection {
734                kind: "discussion",
735                ..
736            })
737        ));
738    }
739
740    #[test]
741    fn read_discussion_rejects_foreign_run_id() {
742        let (_tmp, paths) = setup();
743        let requested = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
744        write_raw(
745            &paths.discussion(&requested),
746            &discussion_json(&format!("d-{ULID_A}"), FOREIGN_RUN),
747        );
748        assert!(matches!(
749            read_discussion(&paths, &requested),
750            Err(Error::CorruptProjection { kind: "discussion_run_id", expected_id, body_id, .. })
751                if expected_id == RUN && body_id == FOREIGN_RUN
752        ));
753    }
754
755    #[test]
756    fn read_spinoff_rejects_body_id_mismatch() {
757        let (_tmp, paths) = setup();
758        let requested = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
759        write_raw(
760            &paths.spinoff(&requested),
761            &spinoff_json(&format!("s-{ULID_B}"), RUN),
762        );
763        assert!(matches!(
764            read_spinoff(&paths, &requested),
765            Err(Error::CorruptProjection { kind: "spinoff", expected_id, body_id, .. })
766                if expected_id == format!("s-{ULID_A}") && body_id == format!("s-{ULID_B}")
767        ));
768    }
769
770    #[test]
771    fn read_spinoff_opt_rejects_body_id_mismatch() {
772        let (_tmp, paths) = setup();
773        let requested = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
774        write_raw(
775            &paths.spinoff(&requested),
776            &spinoff_json(&format!("s-{ULID_B}"), RUN),
777        );
778        assert!(matches!(
779            read_spinoff_opt(&paths, &requested),
780            Err(Error::CorruptProjection {
781                kind: "spinoff",
782                ..
783            })
784        ));
785    }
786
787    #[test]
788    fn read_spinoff_rejects_foreign_run_id() {
789        let (_tmp, paths) = setup();
790        let requested = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
791        write_raw(
792            &paths.spinoff(&requested),
793            &spinoff_json(&format!("s-{ULID_A}"), FOREIGN_RUN),
794        );
795        assert!(matches!(
796            read_spinoff(&paths, &requested),
797            Err(Error::CorruptProjection { kind: "spinoff_run_id", expected_id, body_id, .. })
798                if expected_id == RUN && body_id == FOREIGN_RUN
799        ));
800    }
801
802    #[test]
803    fn read_manifest_rejects_foreign_run_id() {
804        // The manifest is keyed by its directory, so a foreign-run manifest
805        // restored into this run's dir is the same class of corruption.
806        let (_tmp, paths) = setup();
807        write_raw(&paths.manifest(), &manifest_json(FOREIGN_RUN));
808        assert!(matches!(
809            read_manifest(&paths),
810            Err(Error::CorruptProjection { kind: "manifest_run_id", expected_id, body_id, .. })
811                if expected_id == RUN && body_id == FOREIGN_RUN
812        ));
813        // `_opt` must reject it too, not paper over it as `None`.
814        assert!(matches!(
815            read_manifest_opt(&paths),
816            Err(Error::CorruptProjection {
817                kind: "manifest_run_id",
818                ..
819            })
820        ));
821    }
822
823    #[test]
824    fn read_manifest_accepts_matching_run_id() {
825        let (_tmp, paths) = setup();
826        write_raw(&paths.manifest(), &manifest_json(RUN));
827        assert_eq!(read_manifest(&paths).unwrap().run_id.as_str(), RUN);
828    }
829
830    // --- write side: object run_id must equal the run's RunPaths.run_id ----
831
832    #[test]
833    fn write_node_rejects_foreign_run_id() {
834        let (_tmp, paths) = setup();
835        let n: Node = serde_json::from_value(node_json("n-0001", FOREIGN_RUN)).unwrap();
836        assert!(matches!(
837            write_node(&paths, &n),
838            Err(Error::CorruptProjection { kind: "node_run_id", expected_id, body_id, .. })
839                if expected_id == RUN && body_id == FOREIGN_RUN
840        ));
841        // The forged write never touched disk.
842        assert!(!paths.node(&n.node_id).exists());
843    }
844
845    #[test]
846    fn write_node_accepts_matching_run_id() {
847        let (_tmp, paths) = setup();
848        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
849        write_node(&paths, &n).unwrap();
850        assert!(paths.node(&n.node_id).exists());
851    }
852
853    #[test]
854    fn write_discussion_rejects_foreign_run_id() {
855        let (_tmp, paths) = setup();
856        let d: Discussion =
857            serde_json::from_value(discussion_json(&format!("d-{ULID_A}"), FOREIGN_RUN)).unwrap();
858        assert!(matches!(
859            write_discussion(&paths, &d),
860            Err(Error::CorruptProjection { kind: "discussion_run_id", expected_id, body_id, .. })
861                if expected_id == RUN && body_id == FOREIGN_RUN
862        ));
863        assert!(!paths.discussion(&d.discussion_id).exists());
864    }
865
866    #[test]
867    fn write_discussion_accepts_matching_run_id() {
868        let (_tmp, paths) = setup();
869        let d: Discussion =
870            serde_json::from_value(discussion_json(&format!("d-{ULID_A}"), RUN)).unwrap();
871        write_discussion(&paths, &d).unwrap();
872        assert!(paths.discussion(&d.discussion_id).exists());
873    }
874
875    #[test]
876    fn write_spinoff_rejects_foreign_run_id() {
877        let (_tmp, paths) = setup();
878        let s: SpinoffProposal =
879            serde_json::from_value(spinoff_json(&format!("s-{ULID_A}"), FOREIGN_RUN)).unwrap();
880        assert!(matches!(
881            write_spinoff(&paths, &s),
882            Err(Error::CorruptProjection { kind: "spinoff_run_id", expected_id, body_id, .. })
883                if expected_id == RUN && body_id == FOREIGN_RUN
884        ));
885        assert!(!paths.spinoff(&s.proposal_id).exists());
886    }
887
888    #[test]
889    fn write_spinoff_accepts_matching_run_id() {
890        let (_tmp, paths) = setup();
891        let s: SpinoffProposal =
892            serde_json::from_value(spinoff_json(&format!("s-{ULID_A}"), RUN)).unwrap();
893        write_spinoff(&paths, &s).unwrap();
894        assert!(paths.spinoff(&s.proposal_id).exists());
895    }
896
897    #[test]
898    fn write_manifest_rejects_foreign_run_id() {
899        let (_tmp, paths) = setup();
900        let m: Manifest = serde_json::from_value(manifest_json(FOREIGN_RUN)).unwrap();
901        assert!(matches!(
902            write_manifest(&paths, &m),
903            Err(Error::CorruptProjection { kind: "manifest_run_id", expected_id, body_id, .. })
904                if expected_id == RUN && body_id == FOREIGN_RUN
905        ));
906        assert!(!paths.manifest().exists());
907    }
908
909    #[test]
910    fn write_manifest_accepts_matching_run_id() {
911        let (_tmp, paths) = setup();
912        let m: Manifest = serde_json::from_value(manifest_json(RUN)).unwrap();
913        write_manifest(&paths, &m).unwrap();
914        assert!(paths.manifest().exists());
915    }
916
917    // --- symlink containment: a replaced subdir or file is refused ---------
918    //
919    // Each test stores a *valid* projection behind the symlink so the rejection
920    // can only come from the symlink guard, never from a parse/key/run-id check
921    // downstream.
922
923    #[cfg(unix)]
924    #[test]
925    fn read_node_rejects_symlinked_nodes_dir() {
926        use std::os::unix::fs::symlink;
927        let (tmp, paths) = setup();
928        let outside = tmp.path().join("outside");
929        std::fs::create_dir_all(&outside).unwrap();
930        std::fs::remove_dir(paths.nodes_dir()).unwrap();
931        symlink(&outside, paths.nodes_dir()).unwrap();
932        let id = NodeId::parse_str("n-0001").unwrap();
933        write_raw(&outside.join("n-0001.json"), &node_json("n-0001", RUN));
934        assert!(matches!(
935            read_node(&paths, &id),
936            Err(Error::SymlinkSubdir { name: "nodes", .. })
937        ));
938    }
939
940    #[cfg(unix)]
941    #[test]
942    fn read_node_rejects_symlinked_node_file() {
943        use std::os::unix::fs::symlink;
944        let (tmp, paths) = setup();
945        let id = NodeId::parse_str("n-0001").unwrap();
946        let target = tmp.path().join("evil-node.json");
947        write_raw(&target, &node_json("n-0001", RUN));
948        symlink(&target, paths.node(&id)).unwrap();
949        assert!(matches!(
950            read_node(&paths, &id),
951            Err(Error::SymlinkStateFile { name: "node", .. })
952        ));
953    }
954
955    /// The `O_NOFOLLOW` backstop, isolated from the `symlink_metadata` check
956    /// the `checked_*` resolvers run first: calling the leaf reader directly on
957    /// a symlinked projection must fail the *open* with `ELOOP` rather than
958    /// following it. This is the half of the TOCTOU window that survives a leaf
959    /// swapped *after* the `symlink_metadata` check but *before* the open.
960    #[cfg(unix)]
961    #[test]
962    fn read_json_refuses_to_follow_a_symlinked_projection() {
963        use std::os::unix::fs::symlink;
964        let (tmp, _paths) = setup();
965        let target = tmp.path().join("evil-node.json");
966        write_raw(&target, &node_json("n-0001", RUN));
967        let link = tmp.path().join("link-node.json");
968        symlink(&target, &link).unwrap();
969        let err = read_json::<Node>(&link).expect_err("must refuse a symlinked projection");
970        match err {
971            Error::Io { source, .. } => assert_eq!(
972                source.raw_os_error(),
973                Some(libc::ELOOP),
974                "O_NOFOLLOW open of a symlink must report ELOOP, got {source:?}"
975            ),
976            other => panic!("expected Error::Io(ELOOP), got {other:?}"),
977        }
978    }
979
980    #[cfg(unix)]
981    #[test]
982    fn read_discussion_rejects_symlinked_discussions_dir() {
983        use std::os::unix::fs::symlink;
984        let (tmp, paths) = setup();
985        let outside = tmp.path().join("outside");
986        std::fs::create_dir_all(&outside).unwrap();
987        std::fs::remove_dir(paths.discussions_dir()).unwrap();
988        symlink(&outside, paths.discussions_dir()).unwrap();
989        let id = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
990        write_raw(
991            &outside.join(format!("d-{ULID_A}.json")),
992            &discussion_json(&format!("d-{ULID_A}"), RUN),
993        );
994        assert!(matches!(
995            read_discussion(&paths, &id),
996            Err(Error::SymlinkSubdir {
997                name: "discussions",
998                ..
999            })
1000        ));
1001    }
1002
1003    #[cfg(unix)]
1004    #[test]
1005    fn read_discussion_rejects_symlinked_discussion_file() {
1006        use std::os::unix::fs::symlink;
1007        let (tmp, paths) = setup();
1008        let id = DiscussionId::parse_str(&format!("d-{ULID_A}")).unwrap();
1009        let target = tmp.path().join("evil-discussion.json");
1010        write_raw(&target, &discussion_json(&format!("d-{ULID_A}"), RUN));
1011        symlink(&target, paths.discussion(&id)).unwrap();
1012        assert!(matches!(
1013            read_discussion(&paths, &id),
1014            Err(Error::SymlinkStateFile {
1015                name: "discussion",
1016                ..
1017            })
1018        ));
1019    }
1020
1021    #[cfg(unix)]
1022    #[test]
1023    fn read_spinoff_rejects_symlinked_spinoffs_dir() {
1024        use std::os::unix::fs::symlink;
1025        let (tmp, paths) = setup();
1026        let outside = tmp.path().join("outside");
1027        std::fs::create_dir_all(&outside).unwrap();
1028        std::fs::remove_dir(paths.spinoffs_dir()).unwrap();
1029        symlink(&outside, paths.spinoffs_dir()).unwrap();
1030        let id = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
1031        write_raw(
1032            &outside.join(format!("s-{ULID_A}.json")),
1033            &spinoff_json(&format!("s-{ULID_A}"), RUN),
1034        );
1035        assert!(matches!(
1036            read_spinoff(&paths, &id),
1037            Err(Error::SymlinkSubdir {
1038                name: "spinoffs",
1039                ..
1040            })
1041        ));
1042    }
1043
1044    #[cfg(unix)]
1045    #[test]
1046    fn read_spinoff_rejects_symlinked_spinoff_file() {
1047        use std::os::unix::fs::symlink;
1048        let (tmp, paths) = setup();
1049        let id = ProposalId::parse_str(&format!("s-{ULID_A}")).unwrap();
1050        let target = tmp.path().join("evil-spinoff.json");
1051        write_raw(&target, &spinoff_json(&format!("s-{ULID_A}"), RUN));
1052        symlink(&target, paths.spinoff(&id)).unwrap();
1053        assert!(matches!(
1054            read_spinoff(&paths, &id),
1055            Err(Error::SymlinkStateFile {
1056                name: "spinoff",
1057                ..
1058            })
1059        ));
1060    }
1061
1062    #[cfg(unix)]
1063    #[test]
1064    fn write_node_rejects_symlinked_nodes_dir() {
1065        // The write side is guarded too: a symlinked subdir would otherwise
1066        // land the atomic temp+rename outside the run tree.
1067        use std::os::unix::fs::symlink;
1068        let (tmp, paths) = setup();
1069        let outside = tmp.path().join("outside");
1070        std::fs::create_dir_all(&outside).unwrap();
1071        std::fs::remove_dir(paths.nodes_dir()).unwrap();
1072        symlink(&outside, paths.nodes_dir()).unwrap();
1073        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
1074        assert!(matches!(
1075            write_node(&paths, &n),
1076            Err(Error::SymlinkSubdir { name: "nodes", .. })
1077        ));
1078        // The forged write never reached the symlink target.
1079        assert!(!outside.join("n-0001.json").exists());
1080    }
1081
1082    #[cfg(unix)]
1083    #[test]
1084    fn read_node_re_guards_a_run_root_swapped_after_construction() {
1085        // The access-time guard must catch a root that becomes a symlink AFTER
1086        // the (now-checked) constructor ran — the long-lived-handle case. Build
1087        // a clean RunPaths, then swap its root dir for a symlink to an outside
1088        // dir that holds an otherwise-valid node, and confirm the read refuses
1089        // to follow it.
1090        use std::os::unix::fs::symlink;
1091        let tmp = TempDir::new().unwrap();
1092        let root = tmp.path().join("run");
1093        let paths = RunPaths::new(&root, RUN).unwrap();
1094        let id = NodeId::parse_str("n-0001").unwrap();
1095        // Outside target with a real nodes/ and a valid node behind it.
1096        let outside = tmp.path().join("outside");
1097        std::fs::create_dir_all(outside.join("nodes")).unwrap();
1098        write_raw(
1099            &outside.join("nodes/n-0001.json"),
1100            &node_json("n-0001", RUN),
1101        );
1102        // Swap the real run dir for a symlink to `outside`.
1103        std::fs::remove_dir_all(&root).ok();
1104        std::fs::create_dir_all(&root).unwrap();
1105        std::fs::remove_dir(&root).unwrap();
1106        symlink(&outside, &root).unwrap();
1107        assert!(matches!(
1108            read_node(&paths, &id),
1109            Err(Error::SymlinkRunDir { .. })
1110        ));
1111    }
1112
1113    #[cfg(unix)]
1114    #[test]
1115    fn from_validated_rejects_a_symlinked_run_root_at_construction() {
1116        use std::os::unix::fs::symlink;
1117        let tmp = TempDir::new().unwrap();
1118        let real = tmp.path().join("real");
1119        std::fs::create_dir_all(&real).unwrap();
1120        let link = tmp.path().join("link");
1121        symlink(&real, &link).unwrap();
1122        assert!(matches!(
1123            RunPaths::from_validated(link, RunId::parse_str(RUN).unwrap()),
1124            Err(Error::SymlinkRunDir { .. })
1125        ));
1126    }
1127
1128    // --- manifest + write-side file symlink coverage ----------------------
1129
1130    #[cfg(unix)]
1131    #[test]
1132    fn read_manifest_rejects_symlinked_manifest_file() {
1133        use std::os::unix::fs::symlink;
1134        let (tmp, paths) = setup();
1135        let target = tmp.path().join("evil-manifest.json");
1136        write_raw(&target, &manifest_json(RUN));
1137        symlink(&target, paths.manifest()).unwrap();
1138        assert!(matches!(
1139            read_manifest(&paths),
1140            Err(Error::SymlinkStateFile {
1141                name: "manifest",
1142                ..
1143            })
1144        ));
1145    }
1146
1147    #[cfg(unix)]
1148    #[test]
1149    fn write_node_rejects_symlinked_node_file() {
1150        // Write side: a symlinked target file is refused before the atomic
1151        // temp+rename runs, so the forged write never reaches the link target.
1152        use std::os::unix::fs::symlink;
1153        let (tmp, paths) = setup();
1154        let id = NodeId::parse_str("n-0001").unwrap();
1155        let target = tmp.path().join("evil-node.json");
1156        symlink(&target, paths.node(&id)).unwrap();
1157        let n: Node = serde_json::from_value(node_json("n-0001", RUN)).unwrap();
1158        assert!(matches!(
1159            write_node(&paths, &n),
1160            Err(Error::SymlinkStateFile { name: "node", .. })
1161        ));
1162        assert!(!target.exists());
1163    }
1164
1165    #[cfg(unix)]
1166    #[test]
1167    fn read_node_rejects_dangling_symlinked_file() {
1168        // A symlink whose target does not exist is still a symlink — it must be
1169        // rejected as corruption, not treated as an absent file (`None`).
1170        use std::os::unix::fs::symlink;
1171        let (tmp, paths) = setup();
1172        let id = NodeId::parse_str("n-0001").unwrap();
1173        symlink(tmp.path().join("does-not-exist.json"), paths.node(&id)).unwrap();
1174        assert!(matches!(
1175            read_node_opt(&paths, &id),
1176            Err(Error::SymlinkStateFile { name: "node", .. })
1177        ));
1178    }
1179}