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