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::{
19 Discussion, DiscussionId, DiscussionStatus, Manifest, Node, NodeId, ProposalId, RunId,
20 SpinoffProposal, SpinoffStatus, SUPPORTED_STATE_SCHEMAS,
21};
22
23fn 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
49fn 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
60fn checked_node(paths: &RunPaths, id: &NodeId) -> Result<PathBuf> {
62 checked_file(paths, paths.nodes_dir(), "nodes", paths.node(id), "node")
63}
64
65fn 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
76fn 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
87fn 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
131fn 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
151fn 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
172pub 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
181pub 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
197pub(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
208pub 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
218pub 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
235pub 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
248pub 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
258pub 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
275pub(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
284pub 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
294pub 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
311pub(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
320pub(crate) struct DerivedCounters {
325 pub node_count: u32,
327 pub open_discussions: u32,
329 pub pending_spinoffs: u32,
331}
332
333pub(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
363fn 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
373fn 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
389fn 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
409fn 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
435fn 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 const RUN: &str = "01jxsnap000000000000000000";
470 const FOREIGN_RUN: &str = "02jxsnap000000000000000000";
471
472 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 const ULID_A: &str = "01arz3ndektsv4rrffq69g5fav";
571 const ULID_B: &str = "01arz3ndektsv4rrffq69g5faw";
572
573 #[test]
576 fn derive_counters_counts_projection_state_and_ignores_junk() {
577 let (_tmp, paths) = setup();
578 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 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 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 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 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 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 #[test]
658 fn read_node_rejects_body_id_mismatch() {
659 let (_tmp, paths) = setup();
660 let requested = NodeId::parse_str("n-0001").unwrap();
661 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 #[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 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 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}