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