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. Twelve 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            // An absent seq is the caller asking for a line that was
214            // never written — not damage, so not a storage fault.
215            StorageError::Snapshot { op, path, source } => {
216                AxError::failure(AxCode::StorageFatal, op, path.display().to_string())
217                    .with_recovery(format!(
218                        "storage failed ({source}); a snapshot is rebuilt from the ledger, so                          remove {} and free the disk; the next start folds from genesis",
219                        path.display()
220                    ))
221            }
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; stop it \
246                 (Ctrl-C in its terminal, or /quit in its 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}