Skip to main content

zipatch_rs/
error.rs

1/// All failures returned by parsing or applying a `ZiPatch` stream.
2///
3/// Both the parsing layer ([`crate::chunk`]) and the apply layer
4/// ([`crate::apply`]) surface errors through this single enum, so callers
5/// need only one error type in their `match` arms.
6///
7/// # Mapping from standard errors
8///
9/// - [`std::io::Error`] converts automatically via `#[from]` into [`ZiPatchError::Io`].
10/// - [`std::string::FromUtf8Error`] converts automatically into [`ZiPatchError::Utf8Error`].
11/// - [`binrw::Error`] converts automatically into [`ZiPatchError::BinrwError`].
12///
13/// # Example
14///
15/// ```rust
16/// use zipatch_rs::ZiPatchError;
17///
18/// fn describe(e: &ZiPatchError) -> &'static str {
19///     match e {
20///         ZiPatchError::Io(_)               => "I/O error",
21///         ZiPatchError::InvalidMagic        => "not a ZiPatch file",
22///         ZiPatchError::UnknownChunkTag(_)  => "unrecognised chunk",
23///         ZiPatchError::ChecksumMismatch {..}=> "corrupt chunk",
24///         ZiPatchError::TruncatedPatch      => "download incomplete",
25///         _                                 => "other error",
26///     }
27/// }
28/// ```
29#[non_exhaustive]
30#[derive(Debug, thiserror::Error)]
31pub enum ZiPatchError {
32    /// Underlying I/O failure from the patch source or target filesystem.
33    ///
34    /// Raised by the parsing layer when reading from the patch stream fails
35    /// (e.g. a network read error), and by the apply layer when any filesystem
36    /// operation fails (open, seek, write, delete, create-dir).
37    ///
38    /// The wrapped [`std::io::Error`] carries the OS-level error code and kind.
39    /// Use [`std::io::Error::kind`] to distinguish `NotFound`, `PermissionDenied`,
40    /// and similar conditions.
41    #[error("I/O error: {0}")]
42    Io(#[from] std::io::Error),
43
44    /// The 12-byte `ZiPatch` magic header was missing or did not match.
45    ///
46    /// Raised by [`ZiPatchReader::new`](crate::ZiPatchReader::new) and
47    /// [`ZiPatchReader::from_path`](crate::ZiPatchReader::from_path) when
48    /// the first 12 bytes of the source do not equal the expected magic
49    /// sequence `\x91ZIPATCH\r\n\x1a\n`. Typically indicates the caller opened
50    /// the wrong file, or the file is corrupted at the very start.
51    #[error("invalid magic bytes")]
52    InvalidMagic,
53
54    /// A 4-byte chunk tag was not recognised by the parser.
55    ///
56    /// Raised by the chunk dispatcher in [`crate::chunk`] when a tag does not
57    /// match any of `FHDR`, `APLY`, `APFS`, `ADIR`, `DELD`, `SQPK`, or
58    /// `EOF_`. The raw tag bytes are preserved so callers can log them for
59    /// diagnostic purposes.
60    ///
61    /// This can occur with genuinely new chunk types introduced by Square Enix
62    /// in future patches. Upgrading the library or filing an issue is the
63    /// appropriate response.
64    #[error("unknown chunk tag: {0:?}")]
65    UnknownChunkTag([u8; 4]),
66
67    /// A SQPK sub-command byte was not recognised by the parser.
68    ///
69    /// Raised by the SQPK dispatcher in [`crate::chunk`] when the single-byte
70    /// command code inside an `SQPK` chunk does not match any of the known
71    /// values (`A`, `D`, `E`, `F`, `H`, `I`, `T`, `X`). The raw byte is
72    /// preserved for diagnostics.
73    #[error("unknown SQPK command: {0:#x}")]
74    UnknownSqpkCommand(u8),
75
76    /// A chunk's recorded CRC32 did not match the computed CRC32.
77    ///
78    /// Raised by the chunk framing code in [`crate::chunk`] when CRC32
79    /// verification is enabled (the default) and the stored checksum in the
80    /// patch stream does not match the checksum computed over `tag ++ body`.
81    ///
82    /// This indicates a corrupt or partially-written patch file. Disable
83    /// verification with
84    /// [`ZiPatchReader::skip_checksum_verification`](crate::ZiPatchReader::skip_checksum_verification)
85    /// if the source has already been verified out-of-band.
86    ///
87    /// The `tag`, `expected`, and `actual` fields provide enough information
88    /// to log a precise diagnostic message.
89    #[error("CRC32 mismatch on chunk {tag:?}: expected {expected:#010x}, got {actual:#010x}")]
90    ChecksumMismatch {
91        /// Tag of the chunk whose checksum failed.
92        tag: [u8; 4],
93        /// CRC32 stored in the patch file.
94        expected: u32,
95        /// CRC32 computed over the actual chunk bytes.
96        actual: u32,
97    },
98
99    /// DEFLATE decompression of a `SqpkFile` block failed.
100    ///
101    /// Raised by the `SqpkFile` apply logic when `flate2` cannot decompress
102    /// a compressed block payload inside an `AddFile` operation. The wrapped
103    /// [`std::io::Error`] is the decompressor's error, not a filesystem error;
104    /// it is stored as a `#[source]` rather than `#[from]` to keep it distinct
105    /// from [`ZiPatchError::Io`].
106    #[error("decompression error")]
107    Decompress(#[source] std::io::Error),
108
109    /// A field value failed a parser invariant (e.g. negative size).
110    ///
111    /// Raised by chunk-specific parsers in [`crate::chunk`] when a field
112    /// value is syntactically valid (i.e. could be parsed) but violates a
113    /// semantic constraint required by the format — for example, a length
114    /// field that is negative, or a size that overflows the expected range.
115    ///
116    /// The `context` string names the specific field that failed its check,
117    /// giving enough information to locate the offending position in the
118    /// binary format documentation.
119    #[error("invalid field: {context}")]
120    InvalidField {
121        /// Human-readable description of which field was invalid and why.
122        context: &'static str,
123    },
124
125    /// A chunk declared a size larger than the parser's maximum (512 MiB).
126    ///
127    /// Raised by the chunk framing code in [`crate::chunk`] when the
128    /// `body_len` field of a chunk frame exceeds 512 MiB. This guard prevents
129    /// allocating an arbitrarily large buffer from a malformed or malicious
130    /// patch stream. The preserved size value can be logged for diagnostics.
131    #[error("chunk size {0} exceeds maximum")]
132    OversizedChunk(usize),
133
134    /// A `SqpkFile` operation byte was not recognised.
135    ///
136    /// Raised by the `SqpkFile` parser when the single-byte `operation` field
137    /// does not match any of `A` (`AddFile`), `R` (`RemoveAll`), `D` (`DeleteFile`),
138    /// or `M` (`MakeDirTree`). The raw byte is preserved for diagnostics.
139    #[error("unknown SqpkFile operation: {0:#02x}")]
140    UnknownFileOperation(u8),
141
142    /// A UTF-8 decode failed when reading a path or name field.
143    ///
144    /// Raised by the parsing layer when a length-prefixed byte string (e.g. a
145    /// directory name in an `ADIR` chunk or a file path in a `SqpkFile` chunk)
146    /// is not valid UTF-8. FFXIV patch paths are documented as ASCII, so this
147    /// error indicates either a corrupt patch file or an undocumented encoding
148    /// extension.
149    #[error("UTF-8 decode error")]
150    Utf8Error(#[from] std::string::FromUtf8Error),
151
152    /// A `binrw` parser produced an error; wraps the underlying cause.
153    ///
154    /// Several chunk types (notably those using `#[derive(BinRead)]`) delegate
155    /// their parsing to `binrw`. When `binrw` encounters an unexpected byte
156    /// pattern or short read, it returns a [`binrw::Error`] which is wrapped
157    /// here via `#[from]`.
158    ///
159    /// The inner error message usually identifies the field name and byte
160    /// offset where parsing failed.
161    #[error("binrw parse error: {0}")]
162    BinrwError(#[from] binrw::Error),
163
164    /// A `SqpkFile` carried a negative `file_offset` that cannot be applied.
165    ///
166    /// Raised by the `AddFile` arm of the `SqpkFile` apply logic when
167    /// `cmd.file_offset` is negative and therefore cannot be converted to a
168    /// [`u64`] seek position. The wire format stores this field as `i64`;
169    /// a non-negative value is the invariant required for correct application.
170    ///
171    /// The preserved `i64` value can be logged to report the exact field content.
172    #[error("negative file_offset in SqpkFile: {0}")]
173    NegativeFileOffset(i64),
174
175    /// Stream ended without an `EOF_` chunk; download or copy was truncated.
176    ///
177    /// Raised by the chunk framing code when attempting to read the 4-byte
178    /// `body_len` field of the next chunk returns an unexpected EOF. This
179    /// indicates the patch stream ended before the mandatory `EOF_` terminator
180    /// chunk was encountered — the patch file was likely incompletely
181    /// downloaded or written to disk.
182    ///
183    /// Use [`ZiPatchReader::is_complete`](crate::ZiPatchReader::is_complete)
184    /// after iteration to distinguish this case from a clean end of stream.
185    #[error("patch file ended without EOF_ chunk (truncated download?)")]
186    TruncatedPatch,
187
188    /// `SqPack` path resolution refused to fall back to a default platform layout.
189    ///
190    /// Raised by the apply layer's internal `dat_path` and `index_path`
191    /// resolvers when [`crate::apply::ApplyContext::platform`]
192    /// is [`crate::Platform::Unknown`]: the parser tolerates unrecognised
193    /// `platform_id` values from [`SqpkTargetInfo`](crate::chunk::SqpkTargetInfo)
194    /// so that future platforms do not hard-fail parsing, but the apply layer
195    /// refuses to resolve a `.dat`/`.index` path against a guessed-at platform —
196    /// silently misrouting writes to e.g. the `win32` layout would corrupt the
197    /// on-disk install. The wrapped `u16` is the raw `platform_id` carried by
198    /// the offending `TargetInfo` chunk, so callers can surface it to users.
199    ///
200    /// Callers who legitimately know which platform layout to use can override
201    /// the context after the `TargetInfo` chunk has been applied (which
202    /// requires manual chunk iteration rather than
203    /// [`ZiPatchReader::apply_to`](crate::ZiPatchReader::apply_to)) by setting
204    /// [`crate::apply::ApplyContext::platform`] back to a concrete variant.
205    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
206    UnsupportedPlatform(u16),
207
208    /// Apply was cancelled by an [`ApplyObserver`](crate::ApplyObserver).
209    ///
210    /// Raised by [`ZiPatchReader::apply_to`](crate::ZiPatchReader::apply_to)
211    /// when an observer returns
212    /// [`std::ops::ControlFlow::Break`] from
213    /// [`ApplyObserver::on_chunk_applied`](crate::ApplyObserver::on_chunk_applied),
214    /// or when
215    /// [`ApplyObserver::should_cancel`](crate::ApplyObserver::should_cancel)
216    /// returns `true` during a long-running chunk (currently checked between
217    /// blocks of an [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile`
218    /// operation).
219    ///
220    /// Cancellation is best-effort: filesystem changes already applied by
221    /// previous chunks — or by previous blocks within the cancelled chunk —
222    /// are **not** rolled back. The format provides no transactional semantics,
223    /// so callers expecting clean cancellation must perform their own recovery
224    /// at a higher level (e.g. by re-running the patch from scratch against a
225    /// clean install snapshot).
226    #[error("apply cancelled by observer")]
227    Cancelled,
228
229    /// An indexed-apply plan referenced a region whose source bytes are not
230    /// reachable from the current applier.
231    ///
232    /// Raised by [`crate::index::IndexApplier`] when it encounters a
233    /// [`crate::index::PartSource::Unavailable`] region. The builder does not
234    /// emit this variant from any in-tree chunk parser, so reaching it
235    /// typically means the plan was hand-constructed (or deserialized) with an
236    /// explicit [`crate::index::PartSource::Unavailable`] region whose source
237    /// bytes are not in the [`crate::index::PatchSource`] the applier was
238    /// constructed with.
239    #[error(
240        "indexed apply: source bytes unavailable for region at target_offset {target_offset} length {length}"
241    )]
242    IndexSourceUnavailable {
243        /// Target-file offset of the unavailable region.
244        target_offset: u64,
245        /// Length in bytes of the unavailable region.
246        length: u32,
247    },
248
249    /// A [`crate::index::PatchSource::read`] call could not fill the requested
250    /// buffer.
251    ///
252    /// Raised by [`crate::index::IndexApplier`] (via the built-in
253    /// [`crate::index::FilePatchSource`] /
254    /// [`crate::index::MemoryPatchSource`] implementations) when the source
255    /// has fewer than `requested` bytes available at `offset` — i.e. the
256    /// underlying file or buffer is shorter than the plan expected, or the
257    /// caller passed an offset past the end. Indicates either a truncated
258    /// patch source or a plan that does not match the source it is being
259    /// applied against.
260    #[error("patch source too short: offset {offset} requested {requested} bytes")]
261    PatchSourceTooShort {
262        /// Patch-file offset at which the read was attempted.
263        offset: u64,
264        /// Number of bytes the caller asked for.
265        requested: usize,
266    },
267
268    /// A [`crate::index::PatchSource::read`] call asked for a patch index
269    /// outside the source's configured chain.
270    ///
271    /// Raised by [`crate::index::FilePatchSource`] /
272    /// [`crate::index::MemoryPatchSource`] when the applier passes a
273    /// `patch: u32` value `>= count`. Indicates either a stale plan referring
274    /// to a patch the source no longer carries, or an off-by-one in a
275    /// hand-built chain source.
276    #[error("patch index {patch} out of range: source carries {count} patches")]
277    PatchIndexOutOfRange {
278        /// Index that was requested.
279        patch: u32,
280        /// Total number of patches the source was constructed with.
281        count: usize,
282    },
283
284    /// An indexed-plan target or filesystem-op carried a path that escapes the
285    /// install root.
286    ///
287    /// Raised by [`crate::index::PlanBuilder`] when a chunk carries a relative
288    /// path that contains a `..` component, starts with `/` (absolute Unix
289    /// path), or starts with a Windows drive-letter prefix (e.g. `C:\`). The
290    /// indexed builder is a natural choke point for this check; the sequential
291    /// apply path does not currently reject these paths (writes would land
292    /// outside the install root if a malicious patch supplied one), but the
293    /// indexed builder refuses to construct the plan rather than building one
294    /// that would point at the wrong filesystem location. `SqPack`-encoded
295    /// targets ([`crate::index::TargetPath::SqpackDat`] /
296    /// [`crate::index::TargetPath::SqpackIndex`]) are structurally constrained
297    /// by their numeric `(main_id, sub_id, file_id)` triple and are not subject
298    /// to this check.
299    #[error("unsafe target path rejected by index builder: {0:?}")]
300    UnsafeTargetPath(String),
301
302    /// A serialized [`crate::index::Plan`] used a schema version this build
303    /// cannot decode.
304    ///
305    /// Raised by the [`crate::index::Plan`] `Deserialize` impl (under the
306    /// `serde` feature) when the persisted `schema_version` is not equal to
307    /// [`crate::index::Plan::CURRENT_SCHEMA_VERSION`]. The plan layout may
308    /// gain new fields in future minor versions; older readers refuse to
309    /// silently drop them rather than risk an apply against a partial plan.
310    #[error(
311        "indexed plan schema version mismatch: persisted {persisted}, this build expects {expected}"
312    )]
313    PlanSchemaVersionMismatch {
314        /// `schema_version` field stored on the persisted plan.
315        persisted: u32,
316        /// `schema_version` this build supports
317        /// ([`crate::index::Plan::CURRENT_SCHEMA_VERSION`]).
318        expected: u32,
319    },
320
321    /// Two patches in the same chain shared a `name`.
322    ///
323    /// Raised by [`crate::index::PlanBuilder::add_patch`] (and the freestanding
324    /// [`crate::index::build_plan_chain`]) when a patch with the same `name`
325    /// has already been added to this builder. The chain protocol is
326    /// order-sensitive — re-adding the same patch almost always indicates a
327    /// caller accidentally fed the same source into the chain twice, which
328    /// would produce a `Plan` whose `patches` vector carries duplicate entries
329    /// and whose region timelines reapply the same bytes. The builder refuses
330    /// to construct the plan rather than silently letting this slip through.
331    /// The duplicate `name` is preserved so callers can log it.
332    #[error("duplicate patch in chain: {name:?}")]
333    DuplicatePatch {
334        /// The patch name that was added twice.
335        name: String,
336    },
337
338    /// A region's post-write CRC32 did not match the expected value.
339    ///
340    /// Reserved for content-CRC verification flows that operate on plans
341    /// populated by [`crate::index::Plan::compute_crc32`]. The default builder
342    /// only emits [`crate::index::PartExpected::SizeOnly`] / `Zeros` /
343    /// `EmptyBlock`, so this variant is only reached once a plan has had
344    /// CRC32 expectations populated and a downstream check compares observed
345    /// bytes against them.
346    #[error(
347        "CRC32 mismatch at target_offset {target_offset}: expected {expected:#010x}, got {actual:#010x}"
348    )]
349    Crc32Mismatch {
350        /// Target-file offset of the mismatched region.
351        target_offset: u64,
352        /// CRC32 declared by the plan's [`crate::index::PartExpected::Crc32`].
353        expected: u32,
354        /// CRC32 computed over the bytes the applier wrote.
355        actual: u32,
356    },
357}