squib_snapshot/error.rs
1//! `SnapshotError` — wire-stable error variants for the snapshot subsystem.
2//!
3//! Per [11-runtime-core.md § 6](../../../specs/11-runtime-core.md#6-error-types) the
4//! variants map 1:1 to the `fault_message` strings the API server emits on
5//! `PUT /snapshot/create` / `PUT /snapshot/load` failures. Renaming a variant is a
6//! compat-suite golden change (I-RC-8 in 11 § 7), so the variants and their `Display`
7//! shapes are the public contract.
8//!
9//! The variant set in this enum is **richer** than the spec's exemplar — it also
10//! carries the per-step failure modes the implementation needs (`InvalidPath`,
11//! `MemoryWrite`, `Bitcode`). Each is mapped through to a single wire-shape category
12//! by [`SnapshotError::wire_message`] so the public surface stays stable.
13
14use std::path::PathBuf;
15
16use semver::Version;
17use thiserror::Error;
18
19/// Errors produced by the snapshot subsystem.
20///
21/// Variants and their `Display` shapes are wire-stable per I-RC-8: the API layer
22/// surfaces `to_string()` verbatim into the `fault_message` body. Renaming a variant
23/// or its message is a compat-suite golden change.
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum SnapshotError {
27 /// A vCPU did not acknowledge the quiesce request within the timeout.
28 ///
29 /// Surfaces as `503 Service Unavailable` on `PUT /snapshot/create`. The save
30 /// is aborted; the previous on-disk pair (if any) is untouched.
31 #[error("a vCPU did not ack quiesce within the timeout")]
32 QuiesceTimeout,
33
34 /// Snapshot magic mismatch — the file is not a squib-/Firecracker-compatible
35 /// state file.
36 #[error("snapshot magic mismatch (file: {found:#x}, expected: {expected:#x})")]
37 MagicMismatch {
38 /// Magic value read from the file header.
39 found: u64,
40 /// Magic value squib expected (architecture-specific).
41 expected: u64,
42 },
43
44 /// Snapshot version is not loadable by this squib build.
45 ///
46 /// The compat rule mirrors upstream: `major` must match exactly; `minor` must
47 /// be ≤ ours; `patch` is unrestricted.
48 #[error("snapshot version {found} is incompatible with squib's {expected}")]
49 VersionMismatch {
50 /// Version embedded in the file.
51 found: Version,
52 /// Version this squib build emits.
53 expected: Version,
54 },
55
56 /// CRC64 of the file body does not match its trailing checksum.
57 #[error("snapshot CRC64 mismatch")]
58 CrcMismatch,
59
60 /// The file is shorter than the trailing 8-byte CRC.
61 #[error("snapshot file is too short to contain a CRC trailer")]
62 TooShort,
63
64 /// Snapshot deserializes to a structurally compatible state, but the contents
65 /// (sysreg subset, GIC blob shape) are from a different VMM.
66 #[error("snapshot is from a different VMM (sysreg or GIC blob shape mismatch)")]
67 Incompatible,
68
69 /// Atomic-commit failed: the temp file wrote successfully but `rename(2)` did
70 /// not complete. The previous destination pair (if any) is left untouched.
71 #[error("atomic commit (rename) failed: {0}")]
72 AtomicCommitFailed(#[source] std::io::Error),
73
74 /// The user-supplied destination path and the temp-file directory live on
75 /// different filesystems, so `rename(2)` could not be atomic.
76 ///
77 /// Pre-flight check; surfaces *before* any data is written. The remediation is
78 /// for the operator to point the snapshot at a path on the same filesystem as
79 /// the temp directory (or vice-versa).
80 #[error(
81 "snapshot temp-file path is on a different filesystem from the destination \
82 (dest={dest:?}, temp_dir={temp_dir:?}); rename(2) cannot be atomic across mounts"
83 )]
84 AtomicCommitCrossFs {
85 /// User-supplied destination path.
86 dest: PathBuf,
87 /// Directory in which the temp file would have been created.
88 temp_dir: PathBuf,
89 },
90
91 /// Operator handed the API a path that did not pass boundary validation
92 /// (NUL byte, oversized, traversal).
93 #[error("invalid snapshot path: {0}")]
94 InvalidPath(String),
95
96 /// `bitcode` failed to encode or decode the snapshot envelope.
97 ///
98 /// `Display` matches [`Self::wire_message`] so the API server's
99 /// `fault_message` body is byte-equal to the rendered `to_string()`.
100 #[error("snapshot encoding error: {0}")]
101 Bitcode(String),
102
103 /// The file is larger than the squib deserialization size limit.
104 #[error("snapshot exceeds {limit} byte deserialization limit")]
105 SizeLimitExceeded {
106 /// The configured limit.
107 limit: usize,
108 },
109
110 /// Memory file write failed (sparse pwrite, full dump, or fsync).
111 #[error("memory file I/O error: {0}")]
112 MemoryIo(#[source] std::io::Error),
113
114 /// Generic I/O error during state file read/write or fsync.
115 #[error("snapshot I/O error: {0}")]
116 Io(#[source] std::io::Error),
117
118 /// A host-side capture or restore step failed (HVF call returned an error,
119 /// MMDS handle was poisoned, etc.). The string is the underlying cause for
120 /// the `fault_message`; the variant exists to keep host-FFI failures out
121 /// of `Bitcode` and `Io` (both of which connote different remediations).
122 #[error("snapshot capture/restore failure: {0}")]
123 Capture(String),
124}
125
126impl SnapshotError {
127 /// The exact `fault_message` body string this error surfaces to the API.
128 ///
129 /// Stable per I-RC-8 — renaming a variant or its `Display` shape is a
130 /// compat-suite golden change. Single-source-of-truth: this delegates to
131 /// `Display`, so the two cannot drift.
132 #[must_use]
133 pub fn wire_message(&self) -> String {
134 self.to_string()
135 }
136}
137
138impl From<bitcode::Error> for SnapshotError {
139 fn from(err: bitcode::Error) -> Self {
140 Self::Bitcode(err.to_string())
141 }
142}
143
144impl From<std::io::Error> for SnapshotError {
145 fn from(err: std::io::Error) -> Self {
146 Self::Io(err)
147 }
148}
149
150/// Result alias used throughout `squib-snapshot`.
151pub type Result<T, E = SnapshotError> = core::result::Result<T, E>;
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_should_render_quiesce_timeout_with_stable_message() {
159 let err = SnapshotError::QuiesceTimeout;
160 assert_eq!(err.to_string(), err.wire_message());
161 assert_eq!(
162 err.wire_message(),
163 "a vCPU did not ack quiesce within the timeout"
164 );
165 }
166
167 #[test]
168 fn test_should_format_magic_mismatch_with_hex() {
169 let err = SnapshotError::MagicMismatch {
170 found: 0xDEAD_BEEF,
171 expected: 0x0710_1984_AAAA_0000,
172 };
173 let s = err.wire_message();
174 assert!(s.contains("0xdeadbeef"), "msg = {s}");
175 assert!(s.contains("0x7101984aaaa0000"), "msg = {s}");
176 }
177
178 #[test]
179 fn test_should_classify_io_errors_under_io_variant() {
180 let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "no");
181 let err: SnapshotError = io.into();
182 assert!(matches!(err, SnapshotError::Io(_)));
183 }
184
185 #[test]
186 fn test_should_route_atomic_commit_failure_into_dedicated_variant() {
187 let io = std::io::Error::other("rename failed");
188 let err = SnapshotError::AtomicCommitFailed(io);
189 assert!(
190 err.wire_message()
191 .starts_with("atomic commit (rename) failed:")
192 );
193 }
194
195 #[test]
196 fn test_should_describe_cross_fs_with_paths() {
197 let err = SnapshotError::AtomicCommitCrossFs {
198 dest: PathBuf::from("/dest/x.snap"),
199 temp_dir: PathBuf::from("/other-fs"),
200 };
201 let s = err.wire_message();
202 assert!(s.contains("/dest/x.snap"));
203 assert!(s.contains("/other-fs"));
204 }
205}