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