1use 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
20fn 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
46fn 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
57fn checked_node(paths: &RunPaths, id: &NodeId) -> Result<PathBuf> {
59 checked_file(paths, paths.nodes_dir(), "nodes", paths.node(id), "node")
60}
61
62fn 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
106fn 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
126fn 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
147pub 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
156pub 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
172pub(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
183pub 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
193pub 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
210pub 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
223pub(crate) struct DerivedCounters {
228 pub node_count: u32,
230}
231
232pub(crate) fn derive_counters(paths: &RunPaths) -> Result<DerivedCounters> {
251 Ok(DerivedCounters {
252 node_count: count_node_files(paths)?,
253 })
254}
255
256fn 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
266fn 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
282fn 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 const RUN: &str = "01jxsnap000000000000000000";
312 const FOREIGN_RUN: &str = "02jxsnap000000000000000000";
313
314 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 #[test]
373 fn derive_counters_counts_projection_state_and_ignores_junk() {
374 let (_tmp, paths) = setup();
375 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 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 let c = derive_counters(&paths).unwrap();
404 assert_eq!(c.node_count, 0);
405 }
406
407 #[test]
410 fn read_node_rejects_body_id_mismatch() {
411 let (_tmp, paths) = setup();
412 let requested = NodeId::parse_str("n-0001").unwrap();
413 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 #[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 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 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}