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    /// 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}