Skip to main content

zipatch_rs/
error.rs

1//! Error types for parsing, applying, indexing, and verifying ZiPatch streams.
2//!
3//! The crate splits failures along the four real domain boundaries rather
4//! than presenting a single flat enum:
5//!
6//! - [`ParseError`] — surfaced by the parsing layer ([`crate::chunk`] and the
7//!   [`crate::ZiPatchReader`] streaming parser). Pure wire-format concerns: bad
8//!   magic, unknown tags, CRC mismatches, truncated streams, field invariants.
9//! - [`ApplyError`] — surfaced by the apply layer ([`crate::apply`]). Wraps
10//!   [`ParseError`] because the sequential apply driver pulls chunks from a
11//!   [`crate::ZiPatchReader`] internally, and adds apply-side concerns: I/O,
12//!   platform resolution, cancellation, DEFLATE failures.
13//! - [`IndexError`] — surfaced by the indexed pipeline ([`crate::index`]).
14//!   Wraps [`ParseError`] (plan building parses chunks) and carries
15//!   index-only concerns: plan-source mismatches, schema versions, unsafe
16//!   target paths, duplicate-patch chains.
17//! - [`VerifyError`] — surfaced by the verifier ([`crate::verify`] and
18//!   [`crate::index::PlanVerifier`]). Plan-shape invariants and on-disk content
19//!   mismatches.
20//!
21//! [`Error`] is a top-level umbrella that all four `From`-convert into, for
22//! callers that prefer to handle every failure mode behind one enum.
23//!
24//! # Third-party error types
25//!
26//! The library does not expose [`binrw::Error`] in its public surface — a
27//! `binrw` parse failure is wrapped as [`ParseError::Decode`], which carries
28//! the field context and message as owned strings. The original
29//! [`binrw::Error`] is retained internally but not reachable through
30//! [`std::error::Error::source`].
31//!
32//! [`std::io::Error`] is kept as a `#[source]` on the variants that genuinely
33//! wrap one (file open/seek/write failures, parser stream reads), because it
34//! is a standard-library type and callers expect it for I/O operations.
35//! Where the apply or verify layer has the path that failed, the variant
36//! carries an optional [`std::path::PathBuf`] alongside the error so the
37//! consumer does not have to reconstruct it.
38
39use crate::newtypes::{ChunkTag, PatchIndex, SchemaVersion};
40use std::path::PathBuf;
41
42// ---------------------------------------------------------------------------
43// ParseError — wire-format / streaming-decode failures
44// ---------------------------------------------------------------------------
45
46/// Failures produced by the parsing layer ([`crate::chunk`]).
47///
48/// Surfaced by [`crate::ZiPatchReader::new`], [`crate::open_patch`], and
49/// [`crate::ZiPatchReader::next_chunk`]. None of these variants involve the
50/// filesystem of the install target — they describe shapes the parser
51/// rejected.
52///
53/// `#[non_exhaustive]`: parse-error vocabulary grows as the parser surfaces
54/// new structural failure modes.
55#[non_exhaustive]
56#[derive(Debug, thiserror::Error)]
57pub enum ParseError {
58    /// I/O failure reading from the patch source.
59    #[error("I/O error reading patch stream: {source}")]
60    Io {
61        /// Underlying [`std::io::Error`] from the patch source.
62        #[source]
63        source: std::io::Error,
64    },
65
66    /// The 12-byte `ZiPatch` magic header was missing or did not match.
67    #[error("invalid magic bytes")]
68    InvalidMagic,
69
70    /// A 4-byte chunk tag was not recognised by the parser.
71    #[error("unknown chunk tag: {0}")]
72    UnknownChunkTag(ChunkTag),
73
74    /// A SQPK sub-command byte was not recognised by the parser.
75    #[error("unknown SQPK command: {0:#x}")]
76    UnknownSqpkCommand(u8),
77
78    /// A chunk's recorded CRC32 did not match the computed CRC32.
79    #[error("CRC32 mismatch on chunk {tag}: expected {expected:#010x}, got {actual:#010x}")]
80    ChecksumMismatch {
81        /// Tag of the chunk whose checksum failed.
82        tag: ChunkTag,
83        /// CRC32 stored in the patch file.
84        expected: u32,
85        /// CRC32 computed over the actual chunk bytes.
86        actual: u32,
87    },
88
89    /// A field value failed a parser invariant (e.g. negative size).
90    #[error("invalid field: {context}")]
91    InvalidField {
92        /// Human-readable description of which field was invalid and why.
93        context: &'static str,
94    },
95
96    /// A chunk declared a size larger than the parser's maximum (512 MiB).
97    #[error("chunk size {0} exceeds maximum")]
98    OversizedChunk(usize),
99
100    /// A `SqpkFile` operation byte was not recognised.
101    #[error("unknown SqpkFile operation: {0:#02x}")]
102    UnknownFileOperation(u8),
103
104    /// A UTF-8 decode failed when reading a path or name field.
105    #[error("UTF-8 decode error: {0}")]
106    Utf8Error(#[from] std::string::FromUtf8Error),
107
108    /// DEFLATE decompression of a `SqpkCompressedBlock` payload failed.
109    ///
110    /// Surfaced both by the standalone
111    /// [`SqpkCompressedBlock::decompress_into`](crate::chunk::SqpkCompressedBlock::decompress_into)
112    /// helper (a parse-side API on the chunk type) and propagated up through
113    /// apply when the apply layer drives block decompression.
114    #[error("decompression error: {source}")]
115    Decompress {
116        /// Underlying [`std::io::Error`] returned by the decompressor.
117        #[source]
118        source: std::io::Error,
119    },
120
121    /// A binary decode (via the underlying `binrw` parser) failed.
122    ///
123    /// The third-party error type is **not** exposed in this variant — only
124    /// the field context (where parsing failed) and the formatted message
125    /// (what went wrong) are surfaced. Callers should treat this as an
126    /// opaque "the wire format was malformed at this field" signal.
127    #[error("decode error in {context}: {message}")]
128    Decode {
129        /// Static description of which field or chunk failed to decode.
130        context: &'static str,
131        /// Formatted message from the underlying decoder.
132        message: String,
133    },
134
135    /// A `SqpkFile` carried a negative `file_offset` that cannot be applied.
136    #[error("negative file_offset in SqpkFile: {0}")]
137    NegativeFileOffset(i64),
138
139    /// Stream ended without an `EOF_` chunk; download or copy was truncated.
140    #[error("patch file ended without EOF_ chunk (truncated download?)")]
141    TruncatedPatch,
142}
143
144impl From<std::io::Error> for ParseError {
145    fn from(source: std::io::Error) -> Self {
146        ParseError::Io { source }
147    }
148}
149
150impl From<binrw::Error> for ParseError {
151    fn from(err: binrw::Error) -> Self {
152        ParseError::Decode {
153            context: "binrw",
154            message: err.to_string(),
155        }
156    }
157}
158
159//---------------------------------------------------------------------------
160// ApplyError — apply-layer failures
161// ---------------------------------------------------------------------------
162
163/// Failures produced by the apply layer ([`crate::apply`]).
164///
165/// Wraps [`ParseError`] because the sequential apply driver
166/// ([`crate::ApplyConfig::apply_patch`]) parses chunks as it applies them.
167///
168/// `#[non_exhaustive]`: apply-side failure modes (new I/O kinds surfaced
169/// as their own variants, new cancellation reasons) accumulate over time.
170#[non_exhaustive]
171#[derive(Debug, thiserror::Error)]
172pub enum ApplyError {
173    /// A parse failure surfaced while the apply driver was pulling chunks.
174    #[error(transparent)]
175    Parse(#[from] ParseError),
176
177    /// An I/O operation against the install root failed.
178    ///
179    /// `path` carries the file or directory the operation was targeting when
180    /// it has been threaded through; it is `None` for operations whose path
181    /// context is not readily available (e.g. raw `?` propagation from a
182    /// helper that does not know the path).
183    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
184    Io {
185        /// Path the failing operation was targeting, if known.
186        path: Option<PathBuf>,
187        /// Underlying [`std::io::Error`].
188        #[source]
189        source: std::io::Error,
190    },
191
192    /// `SqPack` path resolution refused to fall back to a default platform layout.
193    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
194    UnsupportedPlatform(u16),
195
196    /// Apply was cancelled by an [`crate::ApplyObserver`].
197    #[error("apply cancelled by observer")]
198    Cancelled,
199
200    /// A persisted record's `schema_version` does not match this build.
201    #[error("{kind} schema version mismatch: persisted {found}, this build expects {expected}")]
202    SchemaVersionMismatch {
203        /// Type name whose schema the mismatch refers to.
204        kind: &'static str,
205        /// Persisted version.
206        found: SchemaVersion,
207        /// Version this build supports.
208        expected: SchemaVersion,
209    },
210}
211
212impl From<std::io::Error> for ApplyError {
213    fn from(source: std::io::Error) -> Self {
214        ApplyError::Io { path: None, source }
215    }
216}
217
218impl ApplyError {
219    /// Construct an [`ApplyError::Io`] tagged with the path the operation
220    /// was targeting.
221    #[must_use]
222    pub fn io_at(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
223        ApplyError::Io {
224            path: Some(path.into()),
225            source,
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// IndexError — indexed-pipeline failures
232// ---------------------------------------------------------------------------
233
234/// Failures produced by the indexed pipeline ([`crate::index`]).
235///
236/// `#[non_exhaustive]`: the indexed pipeline is the youngest layer and
237/// has the highest expected churn in surfaced failure modes.
238#[non_exhaustive]
239#[derive(Debug, thiserror::Error)]
240pub enum IndexError {
241    /// A parse failure surfaced while the plan builder was scanning chunks.
242    #[error(transparent)]
243    Parse(#[from] ParseError),
244
245    /// An I/O failure during plan building, source reads, or indexed apply.
246    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
247    Io {
248        /// Path the failing operation was targeting, if known.
249        path: Option<PathBuf>,
250        /// Underlying [`std::io::Error`].
251        #[source]
252        source: std::io::Error,
253    },
254
255    /// `SqPack` path resolution refused to fall back to a default platform layout.
256    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
257    UnsupportedPlatform(u16),
258
259    /// Indexed apply was cancelled by an observer or checkpoint signal.
260    #[error("apply cancelled")]
261    Cancelled,
262
263    /// An indexed-apply plan referenced a region whose source bytes are not
264    /// reachable from the current applier.
265    #[error(
266        "indexed apply: source bytes unavailable for region at target_offset {target_offset} length {length}"
267    )]
268    SourceUnavailable {
269        /// Target-file offset of the unavailable region.
270        target_offset: u64,
271        /// Length in bytes of the unavailable region.
272        length: u32,
273    },
274
275    /// A [`crate::index::PatchSource::read`] call could not fill the requested
276    /// buffer.
277    #[error("patch source too short: offset {offset} requested {requested} bytes")]
278    PatchSourceTooShort {
279        /// Patch-file offset at which the read was attempted.
280        offset: u64,
281        /// Number of bytes the caller asked for.
282        requested: usize,
283    },
284
285    /// A [`crate::index::PatchSource::read`] call asked for a patch index
286    /// outside the source's configured chain.
287    #[error("patch index {patch} out of range: source carries {count} patches")]
288    PatchIndexOutOfRange {
289        /// Index that was requested.
290        patch: PatchIndex,
291        /// Total number of patches the source was constructed with.
292        count: usize,
293    },
294
295    /// An indexed-plan target or filesystem-op carried a path that escapes
296    /// the install root.
297    #[error("unsafe target path rejected by index builder: {0:?}")]
298    UnsafeTargetPath(String),
299
300    /// A persisted record's `schema_version` does not match this build.
301    #[error("{kind} schema version mismatch: persisted {found}, this build expects {expected}")]
302    SchemaVersionMismatch {
303        /// Type name whose schema the mismatch refers to.
304        kind: &'static str,
305        /// Persisted version.
306        found: SchemaVersion,
307        /// Version this build supports.
308        expected: SchemaVersion,
309    },
310
311    /// Two patches in the same chain shared a `name`.
312    #[error("duplicate patch in chain: {name:?}")]
313    DuplicatePatch {
314        /// The patch name that was added twice.
315        name: String,
316    },
317
318    /// A field value failed a builder invariant.
319    #[error("invalid field: {context}")]
320    InvalidField {
321        /// Human-readable description of which field was invalid and why.
322        context: &'static str,
323    },
324}
325
326impl From<std::io::Error> for IndexError {
327    fn from(source: std::io::Error) -> Self {
328        IndexError::Io { path: None, source }
329    }
330}
331
332impl From<ApplyError> for IndexError {
333    fn from(err: ApplyError) -> Self {
334        match err {
335            ApplyError::Parse(p) => IndexError::Parse(p),
336            ApplyError::Io { path, source } => IndexError::Io { path, source },
337            ApplyError::UnsupportedPlatform(id) => IndexError::UnsupportedPlatform(id),
338            ApplyError::Cancelled => IndexError::Cancelled,
339            ApplyError::SchemaVersionMismatch {
340                kind,
341                found,
342                expected,
343            } => IndexError::SchemaVersionMismatch {
344                kind,
345                found,
346                expected,
347            },
348        }
349    }
350}
351
352impl From<ApplyError> for VerifyError {
353    fn from(err: ApplyError) -> Self {
354        match err {
355            ApplyError::Io { path, source } => VerifyError::Io { path, source },
356            ApplyError::UnsupportedPlatform(id) => VerifyError::UnsupportedPlatform(id),
357            other => VerifyError::Io {
358                path: None,
359                source: std::io::Error::other(other.to_string()),
360            },
361        }
362    }
363}
364
365// ---------------------------------------------------------------------------
366// VerifyError — verifier failures
367// ---------------------------------------------------------------------------
368
369/// Failures produced by the verifier ([`crate::verify`] and
370/// [`crate::index::PlanVerifier`]).
371///
372/// `#[non_exhaustive]`: per-failure-mode splits (hash-algorithm mismatch,
373/// truncated source) may be added without a `SemVer` break.
374#[non_exhaustive]
375#[derive(Debug, thiserror::Error)]
376pub enum VerifyError {
377    /// An I/O failure while reading a target file.
378    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
379    Io {
380        /// Path the failing operation was targeting, if known.
381        path: Option<PathBuf>,
382        /// Underlying [`std::io::Error`].
383        #[source]
384        source: std::io::Error,
385    },
386
387    /// `SqPack` path resolution refused to fall back to a default platform layout.
388    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
389    UnsupportedPlatform(u16),
390
391    /// A plan or verification config violated a structural invariant.
392    #[error("invalid field: {context}")]
393    InvalidField {
394        /// Human-readable description of which field was invalid and why.
395        context: &'static str,
396    },
397
398    /// A region's observed CRC32 did not match the expected value.
399    #[error(
400        "CRC32 mismatch at target_offset {target_offset}: expected {expected:#010x}, got {actual:#010x}"
401    )]
402    Crc32Mismatch {
403        /// Target-file offset of the mismatched region.
404        target_offset: u64,
405        /// CRC32 declared by the plan's expectation.
406        expected: u32,
407        /// CRC32 computed over the bytes observed on disk.
408        actual: u32,
409    },
410}
411
412impl From<std::io::Error> for VerifyError {
413    fn from(source: std::io::Error) -> Self {
414        VerifyError::Io { path: None, source }
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Error — top-level umbrella
420// ---------------------------------------------------------------------------
421
422/// `Result` alias for parsing-layer entry points.
423pub type ParseResult<T> = std::result::Result<T, ParseError>;
424/// `Result` alias for apply-layer entry points.
425pub type ApplyResult<T> = std::result::Result<T, ApplyError>;
426/// `Result` alias for indexed-pipeline entry points.
427pub type IndexResult<T> = std::result::Result<T, IndexError>;
428/// `Result` alias for verifier entry points.
429pub type VerifyResult<T> = std::result::Result<T, VerifyError>;
430
431/// Umbrella `Result` alias keyed on the top-level [`Error`] enum.
432///
433/// Use this at the edges of consumer code that just wants a single
434/// `?`-friendly error type covering every domain. Internal APIs continue to
435/// return their domain-specific aliases ([`ParseResult`], [`ApplyResult`],
436/// [`IndexResult`], [`VerifyResult`]).
437pub type Result<T> = std::result::Result<T, Error>;
438
439/// Top-level umbrella that every domain error converts into.
440///
441/// Convenient for callers that want a single `?`-friendly error type at the
442/// edges of their code. Domain-specific entry points still return their
443/// domain-specific type; callers who care about the distinction match on
444/// those, while callers who just want to bail out can use [`Error`].
445///
446/// `#[non_exhaustive]`: future top-level domains (e.g. a network-fetch
447/// layer if one ever lands here) would land as new variants.
448#[non_exhaustive]
449#[derive(Debug, thiserror::Error)]
450pub enum Error {
451    /// A parsing-layer failure.
452    #[error(transparent)]
453    Parse(#[from] ParseError),
454
455    /// An apply-layer failure.
456    #[error(transparent)]
457    Apply(#[from] ApplyError),
458
459    /// An indexed-pipeline failure.
460    #[error(transparent)]
461    Index(#[from] IndexError),
462
463    /// A verifier failure.
464    #[error(transparent)]
465    Verify(#[from] VerifyError),
466}