Skip to main content

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}