Skip to main content

storage/
error.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What this crate's twelve modules say when persistence refuses, and
7//! the one door that turns it into an `AxError`
8//! (`crates/storage/spec/Error.lean` §8-14).
9//!
10//! **Why this is a module of its own.** `crates/storage/Spec.lean` §7 recorded
11//! the condition when the type was born: it lived beside the ledger
12//! while fewer than three modules aggregated here, and moved out at
13//! three. Most of the crate's modules import it today, and most of its
14//! variants describe failures the ledger cannot produce — a corrupt CAS
15//! object, a bundle that is not a city, a worktree that is behind
16//! the trunk. Executing a decision whose stated condition has arrived
17//! needs no new argument.
18//!
19//! One rule holds the type together: **a variant carries the operation,
20//! the subject and the recovery, and never the bytes**, and
21//! `SecretEgress` is where that is load-bearing.
22
23use std::io;
24use std::path::{Path, PathBuf};
25
26use kernel::{AxCode, AxError};
27
28/// Crate root error; crosses the crate boundary only via [`StorageError::into_ax`].
29#[derive(Debug, thiserror::Error)]
30pub enum StorageError {
31    #[error("{op} failed at {path}: {source}")]
32    Io {
33        op: &'static str,
34        path: PathBuf,
35        #[source]
36        source: io::Error,
37    },
38    #[error("ledger at {path} was written by a newer sprawling (v{v})")]
39    VersionAhead { path: PathBuf, v: u64 },
40    #[error("ledger segment {path} is damaged at line {line}")]
41    Envelope {
42        path: PathBuf,
43        line: u64,
44        #[source]
45        source: AxError,
46    },
47    #[error("could not build a ledger draft")]
48    Draft {
49        #[source]
50        source: AxError,
51    },
52    #[error("cas object {hash} is not in the store")]
53    CasMissing { hash: String },
54    #[error("cas object {hash} at {path} does not hash to its address")]
55    CasCorrupt { hash: String, path: PathBuf },
56    #[error("range does not fit cas object {hash}")]
57    RangeOutOfBounds { hash: String },
58    #[error("seq {seq} is not in the ledger index")]
59    SeqMissing { seq: u64 },
60    #[error("checkpoint {op} failed: {detail}")]
61    Checkpoint { op: &'static str, detail: String },
62    /// Locations only. The matched bytes are never carried — proving a
63    /// secret leaked by reprinting it would be the leak.
64    #[error("staged content matches secret shapes at {locations}")]
65    SecretEgress { locations: String },
66    /// An export or restore that is well-formed I/O but not a city:
67    /// an occupied destination, a bundle whose manifest disagrees with
68    /// what arrived, a chain with a gap.
69    #[error("bundle {op} refused: {detail}")]
70    Bundle { op: &'static str, detail: String },
71    /// A tree that cannot be opened, added or pruned. Environment, not
72    /// policy: the repository or the filesystem said no.
73    #[error("worktree {op} failed: {detail}")]
74    Worktree { op: &'static str, detail: String },
75    /// A tree that cannot be granted now: the node already holds one, or
76    /// granting it would cross the ceiling. Both are answered by giving
77    /// a tree back, which is why they share a code.
78    #[error("worktree {name} cannot be opened: {detail}")]
79    WorktreeBusy { name: String, detail: String },
80    /// A node's work that no longer sits on top of the trunk. Not a
81    /// storage fault: the work is intact, and whether it still applies
82    /// is the question a machine must not answer by itself.
83    #[error("worktree {name} is behind the trunk: {detail}")]
84    MergeStale { name: String, detail: String },
85    /// A merge that would overwrite or delete a tracked file the person
86    /// changed in the city folder and has not committed.
87    #[error("merging would discard uncommitted changes to {}", paths.join(", "))]
88    MergeWouldDiscard { paths: Vec<String> },
89    /// A name that is an alias. The family is refused whole (`crates/storage/spec/Alias.lean`
90    /// §8-25): a write through one lands where the name does not say.
91    #[error("{op} refused at {path}: that name is a {kind}")]
92    Alias {
93        op: &'static str,
94        path: PathBuf,
95        kind: crate::alias::AliasKind,
96    },
97    /// Something already stands at the name a create asked for: the
98    /// file system answered at the moment of the write, so a second of
99    /// two racing creates meets this (`crates/storage/spec/Alias.lean` §8-32).
100    #[error("{} already exists", path.display())]
101    NameTaken { path: PathBuf },
102    /// Another handle holds this city's writer lock: a second process,
103    /// or a second ledger in this one (`crates/storage/spec/Jsonl.lean` §8-1).
104    #[error("the ledger at {dir} is held by another writer")]
105    LedgerHeld { dir: PathBuf },
106    /// A wave's write or barrier failed, so what the disk holds past
107    /// `at` is unknown to this handle; only a reopen can judge it
108    /// (`crates/storage/spec/Jsonl.lean` §8-1).
109    #[error("the ledger at {} lost its barrier at seq {}", dir.display(), at.value())]
110    LedgerBroken { dir: PathBuf, at: kernel::Seq },
111    /// A whole-chain audit found a broken line, so this writer stopped
112    /// taking new lines; `source` is the audit's own reason (8-30).
113    #[error("the ledger stopped taking writes because its chain failed verification")]
114    ChainHalted {
115        #[source]
116        source: AxError,
117    },
118    /// This writer waits for the proof of the history it opened from, and
119    /// writes nothing until the proof is whole (8-30).
120    #[error("the ledger takes no line until the history it opened from is proved")]
121    Unproven,
122    /// The disk refused a snapshot read or write. A snapshot is a cache
123    /// the Ledger rebuilds, so its advice differs from `Io`'s (8-26).
124    #[error("{op} failed at {path}: {source}")]
125    Snapshot {
126        op: &'static str,
127        path: PathBuf,
128        #[source]
129        source: io::Error,
130    },
131}
132
133impl StorageError {
134    /// The only exit across the crate boundary.
135    pub fn into_ax(self) -> AxError {
136        match self {
137            // Storage write failure is process-fatal; E_STORAGE_FATAL is its loadtime
138            // code.
139            StorageError::Io { op, path, source } => {
140                AxError::failure(AxCode::StorageFatal, op, path.display().to_string())
141                    .with_recovery(format!(
142                        "storage failed ({source}); halt cleanly — reopening will tail-truncate"
143                    ))
144            }
145            StorageError::VersionAhead { path, v } => AxError::failure(
146                AxCode::LogVersionUnsupported,
147                "open ledger",
148                format!("{} (written by a newer sprawling, v{v})", path.display()),
149            )
150            .with_recovery(format!(
151                "open this ledger with the sprawling that wrote it; original path: {}",
152                path.display()
153            )),
154            StorageError::Envelope { path, line, source } => AxError::failure(
155                AxCode::LogVersionUnsupported,
156                "open ledger",
157                format!("{}:{line}", path.display()),
158            )
159            .with_recovery(format!(
160                "non-tail damage cannot be auto-repaired ({source}); inspect the segment"
161            )),
162            StorageError::Draft { source } | StorageError::ChainHalted { source } => source,
163            StorageError::Unproven => AxError::failure(
164                AxCode::HistoryUnproven,
165                "append to the ledger",
166                "the history this city opened from is still being proved",
167            )
168            .with_recovery(
169                "the city is still proving the history it opened from; send it again once the log says the history is proved",
170            ),
171            StorageError::Worktree { op, detail } => {
172                AxError::failure(AxCode::StorageFatal, op, detail).with_recovery(
173                    "the repository or the filesystem refused; fix that, then claim again",
174                )
175            }
176            StorageError::WorktreeBusy { name, detail } => AxError::failure(
177                AxCode::WorktreeBusy,
178                "open a worktree",
179                format!("{name}: {detail}"),
180            )
181            .with_recovery("release a tree and claim again, or work on a node whose tree is free"),
182            StorageError::MergeStale { name, detail } => AxError::failure(
183                AxCode::VersionConflict,
184                "merge a node's work",
185                format!("{name}: {detail}"),
186            )
187            .with_recovery(
188                "rebuild this node's tree on the trunk as it stands, then have it verified again",
189            ),
190            StorageError::MergeWouldDiscard { paths } => AxError::failure(
191                AxCode::VersionConflict,
192                "merge a node's work",
193                format!("uncommitted changes to {}", paths.join(", ")),
194            )
195            .with_recovery(
196                "commit or set aside these changes in the city folder, then merge again",
197            ),
198            StorageError::CasMissing { hash } => {
199                AxError::failure(AxCode::PathNotFound, "read cas object", hash)
200                    .with_recovery("the locator outlived its object; re-materialize or drop it")
201            }
202            StorageError::CasCorrupt { hash, path } => AxError::failure(
203                AxCode::CasCorrupt,
204                "read cas object",
205                format!("{hash} at {}", path.display()),
206            )
207            .with_recovery("bit rot or outside tampering; restore from export or drop the object"),
208            StorageError::RangeOutOfBounds { hash } => {
209                AxError::failure(AxCode::InvalidArgs, "read cas range", hash)
210                    .with_recovery("range exceeds the object; ask within its size")
211            }
212            StorageError::Snapshot { op, path, source } => {
213                AxError::failure(AxCode::StorageFatal, op, path.display().to_string())
214                    .with_recovery(format!(
215                        "storage failed ({source}); a snapshot is rebuilt from the ledger, so \
216                         remove {} and free the disk; the next start folds from genesis",
217                        path.display()
218                    ))
219            }
220            // An absent seq is the caller asking for a line that was
221            // never written — not damage, so not a storage fault.
222            StorageError::SeqMissing { seq } => {
223                AxError::failure(AxCode::InvalidArgs, "read ledger line", seq.to_string())
224                    .with_recovery("ask for a seq the ledger actually holds")
225            }
226            StorageError::Bundle { op, detail } => {
227                AxError::failure(AxCode::ConfigInvalid, op, detail).with_recovery(
228                    "restore into an empty directory, and copy the bundle again if it is short",
229                )
230            }
231            StorageError::Checkpoint { op, detail } => {
232                AxError::failure(AxCode::WorktreeBusy, op, detail)
233                    .with_recovery("resolve the repository state, then retry the wave")
234            }
235            StorageError::SecretEgress { locations } => {
236                AxError::failure(AxCode::SecretEgress, "commit checkpoint", locations)
237                    .with_recovery("remove the secret from the staged files, then retry")
238            }
239            StorageError::LedgerHeld { dir } => AxError::failure(
240                AxCode::LedgerHeld,
241                "open the ledger for writing",
242                dir.display().to_string(),
243            )
244            .with_recovery(
245                "another sprawling process is serving or changing this city; close it \
246                 (/quit in its terminal, or Ctrl-C where it runs with no console), then run this again",
247            ),
248            StorageError::LedgerBroken { dir, at } => AxError::failure(
249                AxCode::StorageFatal,
250                "append to the ledger",
251                format!("{} at seq {}", dir.display(), at.value()),
252            )
253            .with_recovery(
254                "a write to this ledger failed earlier; free the disk or fix the device, \
255                 then restart sprawling so that opening the ledger repairs its tail",
256            ),
257            StorageError::Alias { op, path, kind } => {
258                AxError::failure(AxCode::OutsideWriteDomain, op, path.display().to_string())
259                    .with_recovery(format!(
260                        "a {kind} at this name reaches a file other than this name; \
261                         replace it with a plain file, then retry"
262                    ))
263            }
264            StorageError::NameTaken { path } => AxError::failure(
265                AxCode::VersionConflict,
266                "create a file",
267                path.display().to_string(),
268            )
269            .with_recovery(
270                "something already stands at this name; read it and change it against its \
271                 version, or create the file under another name",
272            ),
273        }
274    }
275}
276
277/// Wraps a failed filesystem call in the operation and path that were
278/// being attempted. Written as a closure so a caller reads
279/// `.map_err(io_err("read a bundle file", &path))?` at the call it
280/// describes.
281pub(crate) fn io_err(op: &'static str, path: &Path) -> impl FnOnce(io::Error) -> StorageError {
282    let path = path.to_path_buf();
283    move |source| StorageError::Io { op, path, source }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::StorageError;
289
290    /// A recovery the User reads is one sentence: a string continued
291    /// across source lines without its `\` once carried the next line's
292    /// indentation into the snapshot advice.
293    #[test]
294    fn a_snapshot_recovery_reads_as_one_sentence() {
295        let refused = StorageError::Snapshot {
296            op: "write a snapshot",
297            path: "snap".into(),
298            source: std::io::Error::other("disk full"),
299        }
300        .into_ax();
301        assert_eq!(
302            refused.recovery(),
303            "storage failed (disk full); a snapshot is rebuilt from the ledger, so remove snap \
304             and free the disk; the next start folds from genesis"
305        );
306    }
307}