octl_core/error.rs
1//! Error type for `octl-core` file I/O and schema operations.
2
3use std::path::PathBuf;
4
5use crate::schema::Status;
6
7/// Errors raised while reading, writing, or validating run state on disk.
8#[derive(Debug, thiserror::Error)]
9pub enum Error {
10 /// A `cancel_run` was refused because the run is already in a *non-cancelled*
11 /// terminal state (`Done` / `Failed`). Cancelling such a run would claim a
12 /// transition the reducer's terminal-state guard refuses, so the operation
13 /// is rejected up front without mutating any state. An already-`Cancelled`
14 /// run is *not* this error — it converges (see [`crate::cancel_run`]).
15 #[error("run is already terminal ({status:?}), cannot cancel")]
16 RunAlreadyTerminal {
17 /// The run's current terminal status (`Done` or `Failed`).
18 status: Status,
19 },
20
21 /// A per-node [`cancel_node`](crate::cancel_node) named a node id the run's
22 /// event log never introduced (no `node.created`). The node set is derived
23 /// from the source-of-truth log — not the `nodes/*.json` projections — so a
24 /// node whose projection write was crash-interrupted is still resolvable;
25 /// this error means the id is genuinely absent, and the CLI maps it to a
26 /// `node_not_found` user error.
27 #[error("no node {node_id} in this run")]
28 NodeNotFound {
29 /// The rejected node id.
30 node_id: String,
31 },
32
33 /// Filesystem I/O failure with the offending path attached for context.
34 #[error("io error at {path}: {source}")]
35 Io {
36 /// Path the operation was acting on when it failed.
37 path: PathBuf,
38 /// Underlying OS error.
39 #[source]
40 source: std::io::Error,
41 },
42
43 /// I/O failure with no path context (from `?` on a bare `io::Error`).
44 #[error("io error: {0}")]
45 IoBare(#[from] std::io::Error),
46
47 /// JSON (de)serialization failure with the offending path attached.
48 #[error("json error at {path}: {source}")]
49 Json {
50 /// Path of the JSON document being parsed or written.
51 path: PathBuf,
52 /// Underlying `serde_json` error.
53 #[source]
54 source: serde_json::Error,
55 },
56
57 /// JSON failure with no path context (from `?` on a bare `serde_json::Error`).
58 #[error("json error: {0}")]
59 JsonBare(#[from] serde_json::Error),
60
61 /// An `events.jsonl` line could not be parsed or violated an invariant.
62 #[error("corrupt event log at {path}: {reason}")]
63 CorruptEventLog {
64 /// Path to the event log.
65 path: PathBuf,
66 /// Human-readable description of what was malformed.
67 reason: String,
68 },
69
70 /// A `run_id` failed validation when constructing [`crate::paths::RunPaths`].
71 #[error("invalid run_id {run_id:?}: {reason}")]
72 InvalidRunId {
73 /// The rejected run id.
74 run_id: String,
75 /// Why it was rejected.
76 reason: String,
77 },
78
79 /// A projection file's embedded id contradicts where it lives on disk.
80 ///
81 /// Two distinct integrity faults share this variant, told apart by `kind`:
82 ///
83 /// - **Read side** (`kind` = `"node"` / `"discussion"` / `"spinoff"`): the
84 /// body's own id newtype is well-formed but does *not* equal the filename
85 /// key it was requested under — a valid `nodes/n-0002.json` placed at
86 /// `nodes/n-0001.json` deserializes fine yet describes a different node.
87 /// Returning it as `n-0002` would let a later `write_node` clobber a
88 /// third file, so the read is rejected instead.
89 /// - **Write side** (`kind` = `"node_run_id"` / `"discussion_run_id"` /
90 /// `"spinoff_run_id"` / `"manifest_run_id"`): the object's `run_id` does
91 /// not equal the [`crate::paths::RunPaths`] run it would be written under,
92 /// so the write is refused before it can stamp a foreign run's id into
93 /// this run's directory.
94 ///
95 /// This is the projection-integrity guard, distinct from
96 /// [`Error::CorruptEventLog`] (which guards `events.jsonl`). It is **not** a
97 /// path-traversal vector — the keys are already validated id newtypes that
98 /// cannot name a file outside the run directory — but a corruption /
99 /// mis-placement detector. `kind` is a fixed `&'static str` so a caller can
100 /// branch on it; `path` localizes the offending file; `expected_id` /
101 /// `body_id` carry the two ids for an operator to diff.
102 #[error(
103 "corrupt projection ({kind}) at {path}: expected id {expected_id:?}, body has {body_id:?}"
104 )]
105 CorruptProjection {
106 /// Which check fired: a read-side filename-key mismatch (`"node"`,
107 /// `"discussion"`, `"spinoff"`) or a run-id mismatch (`"node_run_id"`,
108 /// `"discussion_run_id"`, `"spinoff_run_id"`, `"manifest_run_id"`),
109 /// which fires on both the read and write side — the fault is identical
110 /// (the object's `run_id` does not equal its directory's run).
111 kind: &'static str,
112 /// The offending projection file (read side) or its intended
113 /// destination (write side), so an operator can go straight to it.
114 path: PathBuf,
115 /// The id the file was expected to carry — the requested filename key
116 /// (read-side key check) or the `RunPaths` run id (run-id check).
117 expected_id: String,
118 /// The id actually found in the file body.
119 body_id: String,
120 },
121
122 /// The run directory itself is a symlink rather than a real directory.
123 ///
124 /// Best-effort symlink containment: [`crate::paths::RunPaths::new`] and every
125 /// projection read/write reject a symlinked run root before any open follows
126 /// it, so a replaced `<root>/runs/<id>` cannot redirect writes outside the
127 /// run tree.
128 ///
129 /// **Trust model.** The state root is `$HOME/.orchestratectl/` — a per-user
130 /// `0700` directory, not a shared multi-user mount. This guards against an
131 /// accidentally- or maliciously-replaced subtree component, not a concurrent
132 /// attacker who already holds write access to the state root.
133 ///
134 /// **Residual gap.** The check is check-then-open: a pure TOCTOU attacker can
135 /// swap the path for a symlink in the window between the `symlink_metadata`
136 /// call and the subsequent open. Closing that needs `O_NOFOLLOW` / `openat2`
137 /// (`RESOLVE_BENEATH` / `RESOLVE_NO_SYMLINKS`), which the standard library
138 /// does not expose portably; it is out of scope for the MVP threat model.
139 #[error("run directory is a symlink (refusing to follow it): {path}")]
140 SymlinkRunDir {
141 /// The symlinked run directory.
142 path: PathBuf,
143 },
144
145 /// A run subdirectory (`nodes/`, `discussions/`, `spinoffs/`) is a symlink.
146 ///
147 /// Same best-effort containment, trust model, and TOCTOU residual gap as
148 /// [`Error::SymlinkRunDir`].
149 #[error("run subdirectory {name:?} is a symlink (refusing to follow it): {path}")]
150 SymlinkSubdir {
151 /// The subdirectory name (`"nodes"`, `"discussions"`, `"spinoffs"`).
152 name: &'static str,
153 /// The symlinked subdirectory path.
154 path: PathBuf,
155 },
156
157 /// A run-state file is a symlink rather than a regular file — covers the
158 /// manifest, the event log, the lock file, and the per-id projection files
159 /// (`name` discriminates: `"manifest"`, `"events"`, `"lock"`, `"node"`,
160 /// `"discussion"`, `"spinoff"`).
161 ///
162 /// Same best-effort containment, trust model, and TOCTOU residual gap as
163 /// [`Error::SymlinkRunDir`]. These files are created by the run itself
164 /// (projection writes go via temp-file + rename, always regular files); a
165 /// symlink in their place is a tampered or corrupted run.
166 #[error("run state file {name:?} is a symlink (refusing to follow it): {path}")]
167 SymlinkStateFile {
168 /// Which state file (`"manifest"`, `"events"`, `"lock"`, `"node"`,
169 /// `"discussion"`, `"spinoff"`).
170 name: &'static str,
171 /// The symlinked file path.
172 path: PathBuf,
173 },
174
175 /// A state file declared a `schema_version` this build does not support.
176 #[error("invalid schema_version {found} (supported: {supported:?}) at {path}")]
177 UnsupportedSchemaVersion {
178 /// Path to the offending state file.
179 path: PathBuf,
180 /// The `schema_version` value read from disk.
181 found: u32,
182 /// Versions this build can read (see [`SUPPORTED_STATE_SCHEMAS`]).
183 ///
184 /// [`SUPPORTED_STATE_SCHEMAS`]: crate::schema::SUPPORTED_STATE_SCHEMAS
185 supported: Vec<u32>,
186 },
187
188 /// An idempotency key was empty.
189 ///
190 /// A `""` key would collapse every "no real key" append into a single
191 /// dedup slot, so [`append_and_apply_idempotent`](crate::append_and_apply_idempotent)
192 /// rejects it in core rather than trusting each CLI boundary to pre-validate.
193 /// The CLI verbs already reject it up front; this is the defense-in-depth
194 /// backstop for any future caller.
195 #[error("idempotency key must not be empty")]
196 EmptyIdempotencyKey,
197}
198
199/// Convenience alias for results returned by `octl-core`.
200pub type Result<T> = std::result::Result<T, Error>;
201
202impl Error {
203 /// Construct an [`Error::Io`] tagging `source` with the `path` it failed on.
204 pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
205 Self::Io {
206 path: path.into(),
207 source,
208 }
209 }
210
211 /// Construct an [`Error::Json`] tagging `source` with the `path` it failed on.
212 pub fn json(path: impl Into<PathBuf>, source: serde_json::Error) -> Self {
213 Self::Json {
214 path: path.into(),
215 source,
216 }
217 }
218}