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