zipatch-rs 1.6.0

Parser for FFXIV ZiPatch patch files
Documentation
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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! Error types for parsing, applying, indexing, and verifying ZiPatch streams.
//!
//! The crate splits failures along the four real domain boundaries rather
//! than presenting a single flat enum:
//!
//! - [`ParseError`] — surfaced by the parsing layer ([`crate::chunk`] and the
//!   [`crate::ZiPatchReader`] streaming parser). Pure wire-format concerns: bad
//!   magic, unknown tags, CRC mismatches, truncated streams, field invariants.
//! - [`ApplyError`] — surfaced by the apply layer ([`crate::apply`]). Wraps
//!   [`ParseError`] because the sequential apply driver pulls chunks from a
//!   [`crate::ZiPatchReader`] internally, and adds apply-side concerns: I/O,
//!   platform resolution, cancellation, DEFLATE failures.
//! - [`IndexError`] — surfaced by the indexed pipeline ([`crate::index`]).
//!   Wraps [`ParseError`] (plan building parses chunks) and carries
//!   index-only concerns: plan-source mismatches, schema versions, unsafe
//!   target paths, duplicate-patch chains.
//! - [`VerifyError`] — surfaced by the verifier ([`crate::verify`] and
//!   [`crate::index::PlanVerifier`]). Plan-shape invariants and on-disk content
//!   mismatches.
//!
//! [`Error`] is a top-level umbrella that all four `From`-convert into, for
//! callers that prefer to handle every failure mode behind one enum.
//!
//! # Third-party error types
//!
//! The library does not expose [`binrw::Error`] in its public surface — a
//! `binrw` parse failure is wrapped as [`ParseError::Decode`], which carries
//! the field context and message as owned strings. The original
//! [`binrw::Error`] is retained internally but not reachable through
//! [`std::error::Error::source`].
//!
//! [`std::io::Error`] is kept as a `#[source]` on the variants that genuinely
//! wrap one (file open/seek/write failures, parser stream reads), because it
//! is a standard-library type and callers expect it for I/O operations.
//! Where the apply or verify layer has the path that failed, the variant
//! carries an optional [`std::path::PathBuf`] alongside the error so the
//! consumer does not have to reconstruct it.

use crate::newtypes::{ChunkTag, PatchIndex, SchemaVersion};
use std::path::PathBuf;

// ---------------------------------------------------------------------------
// ParseError — wire-format / streaming-decode failures
// ---------------------------------------------------------------------------

/// Failures produced by the parsing layer ([`crate::chunk`]).
///
/// Surfaced by [`crate::ZiPatchReader::new`], [`crate::open_patch`], and
/// [`crate::ZiPatchReader::next_chunk`]. None of these variants involve the
/// filesystem of the install target — they describe shapes the parser
/// rejected.
///
/// `#[non_exhaustive]`: parse-error vocabulary grows as the parser surfaces
/// new structural failure modes.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
    /// I/O failure reading from the patch source.
    #[error("I/O error reading patch stream: {source}")]
    Io {
        /// Underlying [`std::io::Error`] from the patch source.
        #[source]
        source: std::io::Error,
    },

    /// The 12-byte `ZiPatch` magic header was missing or did not match.
    #[error("invalid magic bytes")]
    InvalidMagic,

    /// A 4-byte chunk tag was not recognised by the parser.
    #[error("unknown chunk tag: {0}")]
    UnknownChunkTag(ChunkTag),

    /// A SQPK sub-command byte was not recognised by the parser.
    #[error("unknown SQPK command: {0:#x}")]
    UnknownSqpkCommand(u8),

    /// A chunk's recorded CRC32 did not match the computed CRC32.
    #[error("CRC32 mismatch on chunk {tag}: expected {expected:#010x}, got {actual:#010x}")]
    ChecksumMismatch {
        /// Tag of the chunk whose checksum failed.
        tag: ChunkTag,
        /// CRC32 stored in the patch file.
        expected: u32,
        /// CRC32 computed over the actual chunk bytes.
        actual: u32,
    },

    /// A field value failed a parser invariant (e.g. negative size).
    #[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).
    #[error("chunk size {0} exceeds maximum")]
    OversizedChunk(usize),

    /// A `SqpkFile` operation byte was not recognised.
    #[error("unknown SqpkFile operation: {0:#02x}")]
    UnknownFileOperation(u8),

    /// A UTF-8 decode failed when reading a path or name field.
    #[error("UTF-8 decode error: {0}")]
    Utf8Error(#[from] std::string::FromUtf8Error),

    /// DEFLATE decompression of a `SqpkCompressedBlock` payload failed.
    ///
    /// Surfaced both by the standalone
    /// [`SqpkCompressedBlock::decompress_into`](crate::chunk::SqpkCompressedBlock::decompress_into)
    /// helper (a parse-side API on the chunk type) and propagated up through
    /// apply when the apply layer drives block decompression.
    #[error("decompression error: {source}")]
    Decompress {
        /// Underlying [`std::io::Error`] returned by the decompressor.
        #[source]
        source: std::io::Error,
    },

    /// A binary decode (via the underlying `binrw` parser) failed.
    ///
    /// The third-party error type is **not** exposed in this variant — only
    /// the field context (where parsing failed) and the formatted message
    /// (what went wrong) are surfaced. Callers should treat this as an
    /// opaque "the wire format was malformed at this field" signal.
    #[error("decode error in {context}: {message}")]
    Decode {
        /// Static description of which field or chunk failed to decode.
        context: &'static str,
        /// Formatted message from the underlying decoder.
        message: String,
    },

    /// A `SqpkFile` carried a negative `file_offset` that cannot be applied.
    #[error("negative file_offset in SqpkFile: {0}")]
    NegativeFileOffset(i64),

    /// Stream ended without an `EOF_` chunk; download or copy was truncated.
    #[error("patch file ended without EOF_ chunk (truncated download?)")]
    TruncatedPatch,
}

impl From<std::io::Error> for ParseError {
    fn from(source: std::io::Error) -> Self {
        ParseError::Io { source }
    }
}

impl From<binrw::Error> for ParseError {
    fn from(err: binrw::Error) -> Self {
        ParseError::Decode {
            context: "binrw",
            message: err.to_string(),
        }
    }
}

//---------------------------------------------------------------------------
// ApplyError — apply-layer failures
// ---------------------------------------------------------------------------

/// Failures produced by the apply layer ([`crate::apply`]).
///
/// Wraps [`ParseError`] because the sequential apply driver
/// ([`crate::ApplyConfig::apply_patch`]) parses chunks as it applies them.
///
/// `#[non_exhaustive]`: apply-side failure modes (new I/O kinds surfaced
/// as their own variants, new cancellation reasons) accumulate over time.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
    /// A parse failure surfaced while the apply driver was pulling chunks.
    #[error(transparent)]
    Parse(#[from] ParseError),

    /// An I/O operation against the install root failed.
    ///
    /// `path` carries the file or directory the operation was targeting when
    /// it has been threaded through; it is `None` for operations whose path
    /// context is not readily available (e.g. raw `?` propagation from a
    /// helper that does not know the path).
    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
    Io {
        /// Path the failing operation was targeting, if known.
        path: Option<PathBuf>,
        /// Underlying [`std::io::Error`].
        #[source]
        source: std::io::Error,
    },

    /// `SqPack` path resolution refused to fall back to a default platform layout.
    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
    UnsupportedPlatform(u16),

    /// Apply was cancelled by an [`crate::ApplyObserver`].
    #[error("apply cancelled by observer")]
    Cancelled,

    /// A persisted record's `schema_version` does not match this build.
    #[error("{kind} schema version mismatch: persisted {found}, this build expects {expected}")]
    SchemaVersionMismatch {
        /// Type name whose schema the mismatch refers to.
        kind: &'static str,
        /// Persisted version.
        found: SchemaVersion,
        /// Version this build supports.
        expected: SchemaVersion,
    },
}

impl From<std::io::Error> for ApplyError {
    fn from(source: std::io::Error) -> Self {
        ApplyError::Io { path: None, source }
    }
}

impl ApplyError {
    /// Construct an [`ApplyError::Io`] tagged with the path the operation
    /// was targeting.
    #[must_use]
    pub fn io_at(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
        ApplyError::Io {
            path: Some(path.into()),
            source,
        }
    }
}

// ---------------------------------------------------------------------------
// IndexError — indexed-pipeline failures
// ---------------------------------------------------------------------------

/// Failures produced by the indexed pipeline ([`crate::index`]).
///
/// `#[non_exhaustive]`: the indexed pipeline is the youngest layer and
/// has the highest expected churn in surfaced failure modes.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum IndexError {
    /// A parse failure surfaced while the plan builder was scanning chunks.
    #[error(transparent)]
    Parse(#[from] ParseError),

    /// An I/O failure during plan building, source reads, or indexed apply.
    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
    Io {
        /// Path the failing operation was targeting, if known.
        path: Option<PathBuf>,
        /// Underlying [`std::io::Error`].
        #[source]
        source: std::io::Error,
    },

    /// `SqPack` path resolution refused to fall back to a default platform layout.
    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
    UnsupportedPlatform(u16),

    /// Indexed apply was cancelled by an observer or checkpoint signal.
    #[error("apply cancelled")]
    Cancelled,

    /// An indexed-apply plan referenced a region whose source bytes are not
    /// reachable from the current applier.
    #[error(
        "indexed apply: source bytes unavailable for region at target_offset {target_offset} length {length}"
    )]
    SourceUnavailable {
        /// 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.
    #[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.
    #[error("patch index {patch} out of range: source carries {count} patches")]
    PatchIndexOutOfRange {
        /// Index that was requested.
        patch: PatchIndex,
        /// 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.
    #[error("unsafe target path rejected by index builder: {0:?}")]
    UnsafeTargetPath(String),

    /// A persisted record's `schema_version` does not match this build.
    #[error("{kind} schema version mismatch: persisted {found}, this build expects {expected}")]
    SchemaVersionMismatch {
        /// Type name whose schema the mismatch refers to.
        kind: &'static str,
        /// Persisted version.
        found: SchemaVersion,
        /// Version this build supports.
        expected: SchemaVersion,
    },

    /// Two patches in the same chain shared a `name`.
    #[error("duplicate patch in chain: {name:?}")]
    DuplicatePatch {
        /// The patch name that was added twice.
        name: String,
    },

    /// A field value failed a builder invariant.
    #[error("invalid field: {context}")]
    InvalidField {
        /// Human-readable description of which field was invalid and why.
        context: &'static str,
    },
}

impl From<std::io::Error> for IndexError {
    fn from(source: std::io::Error) -> Self {
        IndexError::Io { path: None, source }
    }
}

impl From<ApplyError> for IndexError {
    fn from(err: ApplyError) -> Self {
        match err {
            ApplyError::Parse(p) => IndexError::Parse(p),
            ApplyError::Io { path, source } => IndexError::Io { path, source },
            ApplyError::UnsupportedPlatform(id) => IndexError::UnsupportedPlatform(id),
            ApplyError::Cancelled => IndexError::Cancelled,
            ApplyError::SchemaVersionMismatch {
                kind,
                found,
                expected,
            } => IndexError::SchemaVersionMismatch {
                kind,
                found,
                expected,
            },
        }
    }
}

impl From<ApplyError> for VerifyError {
    fn from(err: ApplyError) -> Self {
        match err {
            ApplyError::Io { path, source } => VerifyError::Io { path, source },
            ApplyError::UnsupportedPlatform(id) => VerifyError::UnsupportedPlatform(id),
            other => VerifyError::Io {
                path: None,
                source: std::io::Error::other(other.to_string()),
            },
        }
    }
}

// ---------------------------------------------------------------------------
// VerifyError — verifier failures
// ---------------------------------------------------------------------------

/// Failures produced by the verifier ([`crate::verify`] and
/// [`crate::index::PlanVerifier`]).
///
/// `#[non_exhaustive]`: per-failure-mode splits (hash-algorithm mismatch,
/// truncated source) may be added without a `SemVer` break.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
    /// An I/O failure while reading a target file.
    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
    Io {
        /// Path the failing operation was targeting, if known.
        path: Option<PathBuf>,
        /// Underlying [`std::io::Error`].
        #[source]
        source: std::io::Error,
    },

    /// `SqPack` path resolution refused to fall back to a default platform layout.
    #[error("unsupported platform id {0}: cannot resolve SqPack path")]
    UnsupportedPlatform(u16),

    /// A plan or verification config violated a structural invariant.
    #[error("invalid field: {context}")]
    InvalidField {
        /// Human-readable description of which field was invalid and why.
        context: &'static str,
    },

    /// A region's observed CRC32 did not match the expected value.
    #[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 expectation.
        expected: u32,
        /// CRC32 computed over the bytes observed on disk.
        actual: u32,
    },
}

impl From<std::io::Error> for VerifyError {
    fn from(source: std::io::Error) -> Self {
        VerifyError::Io { path: None, source }
    }
}

// ---------------------------------------------------------------------------
// Error — top-level umbrella
// ---------------------------------------------------------------------------

/// `Result` alias for parsing-layer entry points.
pub type ParseResult<T> = std::result::Result<T, ParseError>;
/// `Result` alias for apply-layer entry points.
pub type ApplyResult<T> = std::result::Result<T, ApplyError>;
/// `Result` alias for indexed-pipeline entry points.
pub type IndexResult<T> = std::result::Result<T, IndexError>;
/// `Result` alias for verifier entry points.
pub type VerifyResult<T> = std::result::Result<T, VerifyError>;

/// Umbrella `Result` alias keyed on the top-level [`Error`] enum.
///
/// Use this at the edges of consumer code that just wants a single
/// `?`-friendly error type covering every domain. Internal APIs continue to
/// return their domain-specific aliases ([`ParseResult`], [`ApplyResult`],
/// [`IndexResult`], [`VerifyResult`]).
pub type Result<T> = std::result::Result<T, Error>;

/// Top-level umbrella that every domain error converts into.
///
/// Convenient for callers that want a single `?`-friendly error type at the
/// edges of their code. Domain-specific entry points still return their
/// domain-specific type; callers who care about the distinction match on
/// those, while callers who just want to bail out can use [`Error`].
///
/// `#[non_exhaustive]`: future top-level domains (e.g. a network-fetch
/// layer if one ever lands here) would land as new variants.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// A parsing-layer failure.
    #[error(transparent)]
    Parse(#[from] ParseError),

    /// An apply-layer failure.
    #[error(transparent)]
    Apply(#[from] ApplyError),

    /// An indexed-pipeline failure.
    #[error(transparent)]
    Index(#[from] IndexError),

    /// A verifier failure.
    #[error(transparent)]
    Verify(#[from] VerifyError),
}