Skip to main content

djvu_iff/
lib.rs

1//! IFF (Interchange File Format) container parser for DjVu files.
2//!
3//! This module provides two APIs:
4//!
5//! 1. **New spec-based parser** (`parse_form`) — zero-copy, borrowing slices from
6//!    the input byte buffer. Written from the sndjvu.org specification.
7//!
8//! 2. **Legacy API** (`parse`, `Chunk`, `DjvuFile`) — the original tree-based parser
9//!    kept for internal backward compatibility while the rewrite is in progress.
10//!
11//! ## DjVu IFF layout
12//!
13//! ```text
14//! [4] magic   = "AT&T"
15//! [4] id      = "FORM"
16//! [4] length  (big-endian u32, covers form_type + all chunks)
17//! [4] form_type = "DJVU" | "DJVM" | "BM44" | "PM44"
18//! ... chunks
19//! ```
20//!
21//! Each inner chunk:
22//! ```text
23//! [4] id
24//! [4] length  (big-endian u32)
25//! [n] data    (padded to even number of bytes if length is odd)
26//! ```
27
28#![cfg_attr(not(feature = "std"), no_std)]
29#![deny(unsafe_code)]
30
31#[cfg(not(feature = "std"))]
32extern crate alloc;
33
34#[cfg(not(feature = "std"))]
35use alloc::{string::String, vec::Vec};
36#[cfg(feature = "std")]
37use std::{string::String, vec::Vec};
38
39// ---- Error types ------------------------------------------------------------
40
41/// Errors that can occur while parsing the IFF container.
42#[derive(Debug, thiserror::Error, PartialEq, Eq)]
43pub enum IffError {
44    /// Input data is too short to contain a valid IFF file.
45    #[error("input is too short to be a valid IFF file")]
46    TooShort,
47
48    /// The `AT&T` magic bytes were not found at the start of the file.
49    #[error("bad magic bytes: expected AT&T, got {got:?}")]
50    BadMagic { got: [u8; 4] },
51
52    /// The FORM type identifier is not a recognised DjVu type.
53    ///
54    /// Note: this is *not* an error — callers may encounter unknown form types
55    /// in bundled documents and should handle them gracefully.
56    #[error("unknown FORM type: {id:?}")]
57    UnknownFormType { id: [u8; 4] },
58
59    /// A chunk header claims more bytes than are available in the buffer.
60    #[error(
61        "chunk {:?} claims {} bytes but only {} are available",
62        id,
63        claimed,
64        available
65    )]
66    ChunkTooLong {
67        id: [u8; 4],
68        claimed: u32,
69        available: usize,
70    },
71
72    /// The input ended unexpectedly in the middle of a chunk.
73    #[error("unexpected end of input (truncated IFF data)")]
74    Truncated,
75}
76
77/// Original error type used by the legacy implementation.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum LegacyError {
80    /// Input data is shorter than expected.
81    UnexpectedEof,
82    /// A required magic number or tag was not found.
83    InvalidMagic,
84    /// A chunk or field has an invalid length.
85    InvalidLength,
86    /// A required chunk is missing.
87    MissingChunk(&'static str),
88    /// An unsupported feature or version was encountered.
89    Unsupported(&'static str),
90    /// Generic format violation.
91    FormatError(String),
92}
93
94impl core::fmt::Display for LegacyError {
95    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96        match self {
97            LegacyError::UnexpectedEof => write!(f, "unexpected end of input"),
98            LegacyError::InvalidMagic => write!(f, "invalid magic number"),
99            LegacyError::InvalidLength => write!(f, "invalid length"),
100            LegacyError::MissingChunk(id) => write!(f, "missing required chunk: {}", id),
101            LegacyError::Unsupported(msg) => write!(f, "unsupported: {}", msg),
102            LegacyError::FormatError(msg) => write!(f, "format error: {}", msg),
103        }
104    }
105}
106
107#[cfg(feature = "std")]
108impl std::error::Error for LegacyError {}
109
110/// Alias for [`LegacyError`].
111pub use LegacyError as Error;
112
113// ---- IFF chunk types --------------------------------------------------------
114
115/// The 4-byte magic that prefixes every on-disk DjVu IFF stream.
116///
117/// The single source of the literal: writers prepend `&MAGIC` rather than
118/// re-spelling `b"AT&T"`, so the emission seam owns the framing bytes. (A
119/// guard test rejects raw `b"AT&T"`/`b"FORM"` assembly outside this crate.)
120pub const MAGIC: [u8; 4] = *b"AT&T";
121
122/// A 4-byte chunk identifier (e.g., b"FORM", b"INFO", b"Sjbz").
123pub type ChunkId = [u8; 4];
124
125/// A parsed IFF chunk — either a FORM container or a leaf data chunk.
126#[derive(Debug, Clone)]
127pub enum Chunk {
128    /// A FORM container with a secondary ID and child chunks.
129    Form {
130        /// The secondary ID (e.g., b"DJVU", b"DJVM", b"DJVI", b"THUM").
131        secondary_id: ChunkId,
132        /// Total byte length of the FORM payload (from the IFF length field).
133        /// Includes the 4-byte secondary ID and all child chunk bytes.
134        length: u32,
135        /// Child chunks within this FORM.
136        children: Vec<Chunk>,
137    },
138    /// A leaf chunk with raw data.
139    Leaf {
140        /// The chunk ID (e.g., b"INFO", b"Sjbz", b"BG44").
141        id: ChunkId,
142        /// The raw chunk payload bytes.
143        data: Vec<u8>,
144    },
145}
146
147impl Chunk {
148    /// For leaf chunks, return the data slice. For FORM chunks, returns empty slice.
149    pub fn data(&self) -> &[u8] {
150        match self {
151            Chunk::Form { .. } => &[],
152            Chunk::Leaf { data, .. } => data,
153        }
154    }
155
156    /// For FORM chunks, return children. For leaf chunks, returns empty slice.
157    pub fn children(&self) -> &[Chunk] {
158        match self {
159            Chunk::Form { children, .. } => children,
160            Chunk::Leaf { .. } => &[],
161        }
162    }
163
164    /// Return the declared payload length from the IFF length field.
165    ///
166    /// For `Form` chunks, this is the value read from the IFF header — it
167    /// covers the secondary ID (4 bytes) and all children.  For `Leaf`
168    /// chunks, this equals `data().len()`.
169    pub fn payload_length(&self) -> u32 {
170        match self {
171            Chunk::Form { length, .. } => *length,
172            Chunk::Leaf { data, .. } => data.len() as u32,
173        }
174    }
175
176    /// Find the first leaf chunk with the given ID in direct children.
177    pub fn find_first(&self, target_id: &[u8; 4]) -> Option<&Chunk> {
178        self.children().iter().find(|c| match c {
179            Chunk::Leaf { id, .. } => id == target_id,
180            _ => false,
181        })
182    }
183
184    /// Find all leaf chunks with the given ID in direct children.
185    pub fn find_all(&self, target_id: &[u8; 4]) -> Vec<&Chunk> {
186        self.children()
187            .iter()
188            .filter(|c| match c {
189                Chunk::Leaf { id, .. } => id == target_id,
190                _ => false,
191            })
192            .collect()
193    }
194}
195
196/// A parsed DjVu document (the root FORM chunk).
197#[derive(Debug, Clone)]
198pub struct DjvuFile {
199    pub root: Chunk,
200}
201
202/// Parse a DjVu file from raw bytes (legacy tree-based parser).
203///
204/// Expects the file to begin with "AT&T" magic followed by a root FORM chunk.
205pub fn parse(data: &[u8]) -> Result<DjvuFile, Error> {
206    if data.len() < 4 {
207        return Err(Error::UnexpectedEof);
208    }
209    // Check for "AT&T" magic
210    let (magic, rest) = if &data[..4] == b"AT&T" {
211        (&data[..4], &data[4..])
212    } else {
213        // Some files may not have AT&T prefix (bare FORM)
214        (&data[..0], data)
215    };
216    let _ = magic;
217
218    let (root, _) = parse_chunk(rest, 0, 0)?;
219    Ok(DjvuFile { root })
220}
221
222/// Maximum FORM nesting depth. Real DjVu files nest a handful of levels
223/// (DJVM → DJVU → chunks); a deeper chain is malformed and, without this bound,
224/// drives `parse_chunk`/`parse_children` into unbounded recursion → stack
225/// overflow on crafted input.
226const MAX_IFF_DEPTH: u32 = 64;
227
228/// Parse a single chunk starting at `offset` within `data`.
229/// Returns the parsed chunk and the number of bytes consumed (including padding).
230fn parse_chunk(data: &[u8], offset: usize, depth: u32) -> Result<(Chunk, usize), Error> {
231    if depth > MAX_IFF_DEPTH {
232        return Err(Error::InvalidLength);
233    }
234    if offset.checked_add(8).is_none_or(|end| end > data.len()) {
235        return Err(Error::UnexpectedEof);
236    }
237
238    let id: ChunkId = [
239        data[offset],
240        data[offset + 1],
241        data[offset + 2],
242        data[offset + 3],
243    ];
244    let length = u32::from_be_bytes([
245        data[offset + 4],
246        data[offset + 5],
247        data[offset + 6],
248        data[offset + 7],
249    ]);
250
251    let payload_start = offset + 8;
252    // `length` is attacker-controlled (u32, up to 4 GiB). On 32-bit targets
253    // (wasm32) `payload_start + length` can wrap, defeating the bounds check
254    // below and causing a slice panic or a runaway loop. Use checked math.
255    let payload_end = payload_start
256        .checked_add(length as usize)
257        .ok_or(Error::InvalidLength)?;
258
259    if payload_end > data.len() {
260        return Err(Error::UnexpectedEof);
261    }
262
263    // Word-align: next chunk starts at even offset
264    let total = 8usize
265        .checked_add(length as usize)
266        .ok_or(Error::InvalidLength)?;
267    let padded_total = total.checked_add(total % 2).ok_or(Error::InvalidLength)?;
268
269    if &id == b"FORM" {
270        if length < 4 {
271            return Err(Error::InvalidLength);
272        }
273        let secondary_id: ChunkId = [
274            data[payload_start],
275            data[payload_start + 1],
276            data[payload_start + 2],
277            data[payload_start + 3],
278        ];
279
280        let children_start = payload_start + 4;
281        let children = parse_children(data, children_start, payload_end, depth + 1)?;
282
283        Ok((
284            Chunk::Form {
285                secondary_id,
286                length,
287                children,
288            },
289            padded_total,
290        ))
291    } else {
292        let chunk_data = data[payload_start..payload_end].to_vec();
293        Ok((
294            Chunk::Leaf {
295                id,
296                data: chunk_data,
297            },
298            padded_total,
299        ))
300    }
301}
302
303/// Parse sequential chunks within a range of bytes.
304fn parse_children(data: &[u8], start: usize, end: usize, depth: u32) -> Result<Vec<Chunk>, Error> {
305    let mut chunks = Vec::new();
306    let mut pos = start;
307
308    while pos < end {
309        if pos + 8 > end {
310            // Trailing bytes — some files have junk at end; tolerate it
311            break;
312        }
313        let (chunk, consumed) = parse_chunk(data, pos, depth)?;
314        chunks.push(chunk);
315        // `consumed` is padded_total ≥ 8, so this always advances; checked to
316        // stay sound under any future change and on 32-bit targets.
317        pos = pos.checked_add(consumed).ok_or(Error::InvalidLength)?;
318    }
319
320    Ok(chunks)
321}
322
323// ---- Legacy emitter (round-trip support, #195) ------------------------------
324
325/// Serialise a `DjvuFile` (legacy parser) back into the on-disk IFF byte
326/// stream, including the leading "AT&T" magic.
327///
328/// Parser/emitter contract: `parse(emit(file)) == file` for any tree
329/// previously produced by `parse(...)`. This is used by property-based
330/// round-trip tests under `tests/proptest_codecs.rs` (#195) and is small
331/// enough to keep alongside the parser; not intended as a general-purpose
332/// DjVu writer.
333pub fn emit(file: &DjvuFile) -> Vec<u8> {
334    let mut out = Vec::with_capacity(64);
335    out.extend_from_slice(&MAGIC);
336    emit_chunk(&file.root, &mut out);
337    out
338}
339
340fn emit_chunk(chunk: &Chunk, out: &mut Vec<u8>) {
341    emit_chunk_inner(chunk, out, false);
342}
343
344fn emit_chunk_inner(chunk: &Chunk, out: &mut Vec<u8>, suppress_inner_pad: bool) {
345    match chunk {
346        Chunk::Form {
347            secondary_id,
348            length: stored_length,
349            children,
350        } => {
351            // Two valid IFF layouts exist for a FORM whose last child has odd
352            // payload length:
353            //   (A) FORM declared length is odd, no pad after last child;
354            //       the outer/parent loop writes the alignment byte.
355            //   (B) FORM declared length is even, includes a pad byte after
356            //       the last child inside the FORM body.
357            // Real DjVu files mix both styles. Preserve the parser's stored
358            // length parity so unmutated subtrees round-trip byte-identical.
359            let suppress_last_pad = (*stored_length & 1) == 1;
360            let mut payload: Vec<u8> = Vec::new();
361            payload.extend_from_slice(secondary_id);
362            let n = children.len();
363            for (i, child) in children.iter().enumerate() {
364                let last = i + 1 == n;
365                emit_chunk_inner(child, &mut payload, last && suppress_last_pad);
366            }
367            let len = payload.len() as u32;
368            out.extend_from_slice(b"FORM");
369            out.extend_from_slice(&len.to_be_bytes());
370            out.extend_from_slice(&payload);
371            // Outer pad to align the next sibling in our parent. Skip when
372            // our parent told us they'll provide alignment for us.
373            let total = 8 + payload.len();
374            if !suppress_inner_pad && total % 2 == 1 {
375                out.push(0);
376            }
377        }
378        Chunk::Leaf { id, data } => {
379            let len = data.len() as u32;
380            out.extend_from_slice(id);
381            out.extend_from_slice(&len.to_be_bytes());
382            out.extend_from_slice(data);
383            let total = 8 + data.len();
384            if !suppress_inner_pad && total % 2 == 1 {
385                out.push(0);
386            }
387        }
388    }
389}
390
391/// Number of bytes [`emit`] writes for `chunk`: the 8-byte header, the payload,
392/// and any word-alignment pad byte.
393///
394/// This is the single source of the framing/size arithmetic. It walks the same
395/// `suppress_last_pad` parity rule as [`emit_chunk_inner`], so `emitted_size`
396/// and `emit` can never disagree — a guarantee callers that pre-compute byte
397/// offsets (e.g. DIRM offset recomputation in the document mutator) rely on for
398/// correctness.
399pub fn emitted_size(chunk: &Chunk) -> usize {
400    emitted_size_inner(chunk, false)
401}
402
403fn emitted_size_inner(chunk: &Chunk, suppress_inner_pad: bool) -> usize {
404    match chunk {
405        Chunk::Form {
406            length: stored_length,
407            children,
408            ..
409        } => {
410            let suppress_last_pad = (*stored_length & 1) == 1;
411            let n = children.len();
412            let mut payload = 4usize; // secondary_id
413            for (i, child) in children.iter().enumerate() {
414                let last = i + 1 == n;
415                payload += emitted_size_inner(child, last && suppress_last_pad);
416            }
417            let total = 8 + payload;
418            total + usize::from(!suppress_inner_pad && total % 2 == 1)
419        }
420        Chunk::Leaf { data, .. } => {
421            let total = 8 + data.len();
422            total + usize::from(!suppress_inner_pad && total % 2 == 1)
423        }
424    }
425}
426
427/// One child for [`partial_emit`]: a parsed [`Chunk`] to re-frame, a verbatim
428/// byte slice copied as-is, or a nested `FORM` container framed from its body.
429pub enum EmitPart<'a> {
430    /// Re-frame this chunk through the canonical emitter (8-byte header,
431    /// payload, word-alignment pad).
432    Chunk(&'a Chunk),
433    /// Copy these bytes into the FORM payload verbatim. Use this for children
434    /// whose bytes must be preserved exactly (the byte-preserving path); any
435    /// word-alignment pad is added by [`partial_emit`] if the slice has odd
436    /// length, so callers may pass either padded or unpadded child blocks.
437    Verbatim(&'a [u8]),
438    /// Frame a nested `FORM` container whose *body* is given verbatim. `body`
439    /// starts with the 4-byte secondary id (`DJVU`/`DJVI`/`THUM`/…); the seam
440    /// writes the `FORM` tag, the big-endian length, the body, and the
441    /// word-alignment pad. Use this for the component sub-FORMs of a bundle so
442    /// the `FORM` framing is never hand-rolled at the call site (and so the
443    /// component's start offset is reported by [`partial_emit_with_offsets`]).
444    Form(&'a [u8]),
445}
446
447/// Emit a complete DjVu file (`AT&T` magic + one root `FORM`) whose children
448/// are a mix of re-framed chunks and verbatim original slices.
449///
450/// This is the byte-preserving counterpart to [`emit`]: untouched children pass
451/// through as [`EmitPart::Verbatim`] (their original bytes), while edited
452/// children are re-framed as [`EmitPart::Chunk`]. Every child is word-aligned
453/// inside the payload, and the FORM length is computed here — through the same
454/// framing rules as [`emit`] / [`emitted_size`], so the three can't drift.
455///
456/// Returns `None` if the assembled FORM payload exceeds `u32::MAX`.
457pub fn partial_emit(secondary_id: ChunkId, parts: &[EmitPart<'_>]) -> Option<Vec<u8>> {
458    partial_emit_with_offsets(secondary_id, parts).map(|(bytes, _)| bytes)
459}
460
461/// Like [`partial_emit`], but also returns the absolute file-byte offset of
462/// each part within the returned buffer: `offsets[i]` is the index at which
463/// `parts[i]`'s framing begins, measured from the start of the leading `AT&T`
464/// magic.
465///
466/// This is the seam for writers that must record an external index of where
467/// each component landed — most notably a bundled `FORM:DJVM`, whose `DIRM`
468/// offset table stores the file offset of every component `FORM`. Those
469/// offsets live *inside* one part (the `DIRM`) yet describe the *others*, so
470/// such a writer is inherently two-pass: emit once to learn the offsets, write
471/// them into the `DIRM`, then emit again. The second pass yields identical
472/// offsets — a part's position depends only on the sizes of the parts before
473/// it, and a fixed-width offset table does not change size when its values
474/// change — so the two passes cannot disagree.
475///
476/// Returns `None` if the assembled FORM payload (or any [`EmitPart::Form`]
477/// body) exceeds `u32::MAX`.
478pub fn partial_emit_with_offsets(
479    secondary_id: ChunkId,
480    parts: &[EmitPart<'_>],
481) -> Option<(Vec<u8>, Vec<usize>)> {
482    // The file prologue before the payload is AT&T(4) + FORM(4) + length(4) =
483    // 12 bytes, so a part written while the payload already holds `k` bytes
484    // begins at file offset 12 + k.
485    const PROLOGUE: usize = 12;
486    let mut payload = Vec::new();
487    payload.extend_from_slice(&secondary_id); // even start (4 bytes)
488    let mut offsets = Vec::with_capacity(parts.len());
489    for part in parts {
490        offsets.push(PROLOGUE + payload.len());
491        match part {
492            EmitPart::Chunk(chunk) => emit_chunk(chunk, &mut payload),
493            EmitPart::Verbatim(bytes) => {
494                payload.extend_from_slice(bytes);
495                if payload.len() % 2 == 1 {
496                    payload.push(0);
497                }
498            }
499            EmitPart::Form(body) => {
500                let len = u32::try_from(body.len()).ok()?;
501                payload.extend_from_slice(b"FORM");
502                payload.extend_from_slice(&len.to_be_bytes());
503                payload.extend_from_slice(body);
504                if payload.len() % 2 == 1 {
505                    payload.push(0);
506                }
507            }
508        }
509    }
510    let len = u32::try_from(payload.len()).ok()?;
511    let mut out = Vec::with_capacity(8 + payload.len());
512    out.extend_from_slice(&MAGIC);
513    out.extend_from_slice(b"FORM");
514    out.extend_from_slice(&len.to_be_bytes());
515    out.extend_from_slice(&payload);
516    // Payload stays even (even start + self-aligned parts), so no outer pad is
517    // ever needed; guard defensively to keep the invariant explicit.
518    if (8 + payload.len()) % 2 == 1 {
519        out.push(0);
520    }
521    Some((out, offsets))
522}
523
524// ---- New spec-based IFF parser (phase 1) ------------------------------------
525//
526// `parse_form` is a new zero-copy parser written from the sndjvu.org spec.
527// It returns `Form` and `IffChunk` types (distinct from the legacy `Chunk`).
528
529/// A parsed IFF chunk from the new spec-based parser: a 4-byte identifier
530/// plus a zero-copy slice into the original byte buffer.
531#[derive(Debug, Clone, Copy)]
532pub struct IffChunk<'a> {
533    /// The 4-byte ASCII chunk identifier.
534    pub id: [u8; 4],
535    /// The raw data bytes of this chunk (not including id or length header).
536    pub data: &'a [u8],
537}
538
539/// The top-level FORM structure parsed by the spec-based parser.
540#[derive(Debug)]
541pub struct Form<'a> {
542    /// The 4-byte FORM type (e.g. `DJVU`, `DJVM`, `BM44`, `PM44`).
543    pub form_type: [u8; 4],
544    /// All chunks contained within the FORM, in order.
545    pub chunks: Vec<IffChunk<'a>>,
546}
547
548/// Parse a DjVu IFF byte stream into a [`Form`].
549///
550/// This is the new spec-based zero-copy parser. It returns borrowed data
551/// from the input slice.
552///
553/// # Errors
554///
555/// Returns [`IffError`] if:
556/// - The data does not begin with the `AT&T` magic bytes
557/// - The FORM chunk header is missing or malformed
558/// - Any chunk extends beyond the available data
559pub fn parse_form(data: &[u8]) -> Result<Form<'_>, IffError> {
560    // Need at least: magic(4) + FORM id(4) + length(4) + form_type(4) = 16 bytes
561    if data.len() < 16 {
562        return Err(IffError::TooShort);
563    }
564
565    // Verify AT&T magic prefix
566    let magic = read_4(data, 0)?;
567    if &magic != b"AT&T" {
568        return Err(IffError::BadMagic { got: magic });
569    }
570
571    // Read FORM chunk id
572    let form_id = read_4(data, 4)?;
573    if &form_id != b"FORM" {
574        return Err(IffError::Truncated);
575    }
576
577    // Read FORM length (big-endian u32)
578    let form_len = read_u32_be(data, 8)? as usize;
579
580    // FORM data starts at byte 12 and must fit within the buffer
581    let form_data_end = 12_usize.checked_add(form_len).ok_or(IffError::Truncated)?;
582    if form_data_end > data.len() {
583        return Err(IffError::ChunkTooLong {
584            id: *b"FORM",
585            claimed: form_len as u32,
586            available: data.len().saturating_sub(12),
587        });
588    }
589
590    // Read form_type (first 4 bytes of FORM data)
591    if form_len < 4 {
592        return Err(IffError::Truncated);
593    }
594    let form_type = read_4(data, 12)?;
595
596    // Parse chunks from the FORM body (after form_type)
597    let body = data.get(16..form_data_end).ok_or(IffError::Truncated)?;
598
599    let chunks = parse_form_body(body)?;
600
601    Ok(Form { form_type, chunks })
602}
603
604/// Parse a sequence of IFF chunks from a FORM body (the bytes *after* the
605/// 4-byte form type), returning zero-copy [`IffChunk`] slices.
606///
607/// Each chunk is: `[4-byte id][4-byte big-endian length][length bytes data]`,
608/// with data padded to an even byte boundary. This is the single chunk-walker
609/// shared by the document reader, the mutator, and DJVM merge/split — callers
610/// that already stripped the `AT&T`/`FORM`/length/form-type prologue (e.g. a
611/// sub-FORM body, or a `FORM:DJVU` page extracted from a bundle) pass the
612/// remaining bytes here instead of re-implementing the walk.
613pub fn parse_form_body(mut buf: &[u8]) -> Result<Vec<IffChunk<'_>>, IffError> {
614    let mut chunks = Vec::new();
615
616    while buf.len() >= 8 {
617        let id = read_4(buf, 0)?;
618        let data_len = read_u32_be(buf, 4)? as usize;
619
620        let data_start = 8_usize;
621        let data_end = data_start
622            .checked_add(data_len)
623            .ok_or(IffError::Truncated)?;
624
625        if data_end > buf.len() {
626            return Err(IffError::ChunkTooLong {
627                id,
628                claimed: data_len as u32,
629                available: buf.len().saturating_sub(data_start),
630            });
631        }
632
633        let chunk_data = buf.get(data_start..data_end).ok_or(IffError::Truncated)?;
634        chunks.push(IffChunk {
635            id,
636            data: chunk_data,
637        });
638
639        // Advance past this chunk; pad to even boundary
640        let padded_len = data_len + (data_len & 1);
641        let next = data_start
642            .checked_add(padded_len)
643            .ok_or(IffError::Truncated)?;
644
645        // Clamp to buf length to handle trailing padding gracefully
646        buf = buf.get(next.min(buf.len())..).ok_or(IffError::Truncated)?;
647    }
648
649    Ok(chunks)
650}
651
652/// Read 4 bytes from `data` at `offset` as a `[u8; 4]`.
653#[inline]
654fn read_4(data: &[u8], offset: usize) -> Result<[u8; 4], IffError> {
655    data.get(offset..offset + 4)
656        .and_then(|s| s.try_into().ok())
657        .ok_or(IffError::Truncated)
658}
659
660/// Read a big-endian `u32` from `data` at `offset`.
661#[inline]
662fn read_u32_be(data: &[u8], offset: usize) -> Result<u32, IffError> {
663    let b = read_4(data, offset)?;
664    Ok(u32::from_be_bytes(b))
665}
666
667// ---- Legacy dump helper (tests only) ----------------------------------------
668
669/// Produce a structural dump of the chunk tree.
670#[cfg(test)]
671pub fn dump(file: &DjvuFile) -> String {
672    let mut out = String::new();
673    dump_chunk(&file.root, 1, &mut out);
674    out
675}
676
677#[cfg(test)]
678fn dump_chunk(chunk: &Chunk, depth: usize, out: &mut String) {
679    let indent = "  ".repeat(depth);
680    match chunk {
681        Chunk::Form {
682            secondary_id,
683            length,
684            children,
685        } => {
686            let sec = std::str::from_utf8(secondary_id).unwrap_or("????");
687            out.push_str(&format!("{}FORM:{} [{}] \n", indent, sec, length));
688            for child in children {
689                dump_chunk(child, depth + 1, out);
690            }
691        }
692        Chunk::Leaf { id, data } => {
693            let id_str = std::str::from_utf8(id).unwrap_or("????");
694            out.push_str(&format!("{}{} [{}] \n", indent, id_str, data.len()));
695        }
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    /// A chain of FORMs nested far deeper than `MAX_IFF_DEPTH` must be rejected,
704    /// not recurse until the stack overflows (security finding).
705    #[test]
706    fn deeply_nested_forms_are_rejected_not_overflow() {
707        // innermost: FORM + len(4) + secondary "DJVU" (even-length, no padding)
708        let mut buf = Vec::new();
709        buf.extend_from_slice(b"FORM");
710        buf.extend_from_slice(&4u32.to_be_bytes());
711        buf.extend_from_slice(b"DJVU");
712        for _ in 0..200 {
713            let inner = buf;
714            let len = 4 + inner.len();
715            let mut outer = Vec::new();
716            outer.extend_from_slice(b"FORM");
717            outer.extend_from_slice(&(len as u32).to_be_bytes());
718            outer.extend_from_slice(b"DJVU");
719            outer.extend_from_slice(&inner);
720            buf = outer;
721        }
722        let mut full = Vec::from(*b"AT&T");
723        full.extend_from_slice(&buf);
724        assert!(
725            parse(&full).is_err(),
726            "deep nesting must error, not overflow"
727        );
728    }
729
730    /// A chunk length that overflows `usize` math must be rejected (32-bit / wasm32
731    /// safety — `payload_start + length` must not wrap).
732    #[test]
733    fn overflowing_chunk_length_is_rejected() {
734        let mut data = Vec::from(*b"AT&T");
735        data.extend_from_slice(b"JUNK");
736        data.extend_from_slice(&u32::MAX.to_be_bytes()); // 4 GiB claimed length
737        data.extend_from_slice(b"\x00\x00");
738        assert!(parse(&data).is_err());
739    }
740
741    fn assets_path() -> std::path::PathBuf {
742        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
743            .join("../../references/djvujs/library/assets")
744    }
745
746    fn golden_path() -> std::path::PathBuf {
747        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/golden/iff")
748    }
749
750    // ---- Legacy parser tests ------------------------------------------------
751
752    /// Parse our structural dump and djvudump output to comparable lines.
753    fn normalize_dump(input: &str) -> Vec<String> {
754        input
755            .lines()
756            .filter(|l| !l.trim().is_empty())
757            .map(|line| {
758                let trimmed = line.trim_end();
759                if let Some(bracket_end) = trimmed.find(']') {
760                    let structural = &trimmed[..=bracket_end];
761                    structural.trim_end().to_string()
762                } else {
763                    trimmed.to_string()
764                }
765            })
766            .collect()
767    }
768
769    fn assert_structure_matches(djvu_file: &str, golden_file: &str) {
770        let data = std::fs::read(assets_path().join(djvu_file)).unwrap();
771        let file = parse(&data).unwrap();
772        let actual = dump(&file);
773        let expected = std::fs::read_to_string(golden_path().join(golden_file)).unwrap();
774
775        let actual_lines = normalize_dump(&actual);
776        let expected_lines = normalize_dump(&expected);
777
778        assert_eq!(
779            actual_lines.len(),
780            expected_lines.len(),
781            "Line count mismatch for {} ({} vs {})",
782            djvu_file,
783            actual_lines.len(),
784            expected_lines.len()
785        );
786
787        for (i, (a, e)) in actual_lines.iter().zip(expected_lines.iter()).enumerate() {
788            assert_eq!(
789                a,
790                e,
791                "Line {} mismatch for {}\n  actual:   {:?}\n  expected: {:?}",
792                i + 1,
793                djvu_file,
794                a,
795                e
796            );
797        }
798    }
799
800    #[test]
801    fn parse_boy_jb2_legacy() {
802        let data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
803        let file = parse(&data).unwrap();
804
805        match &file.root {
806            Chunk::Form {
807                secondary_id,
808                children,
809                ..
810            } => {
811                assert_eq!(secondary_id, b"DJVU");
812                assert_eq!(children.len(), 2);
813            }
814            _ => panic!("expected FORM root"),
815        }
816    }
817
818    #[test]
819    fn structure_boy_jb2() {
820        assert_structure_matches("boy_jb2.djvu", "boy_jb2.dump");
821    }
822
823    #[test]
824    fn structure_boy() {
825        assert_structure_matches("boy.djvu", "boy.dump");
826    }
827
828    #[test]
829    fn structure_chicken() {
830        assert_structure_matches("chicken.djvu", "chicken.dump");
831    }
832
833    #[test]
834    fn structure_carte() {
835        assert_structure_matches("carte.djvu", "carte.dump");
836    }
837
838    #[test]
839    fn structure_navm_fgbz() {
840        assert_structure_matches("navm_fgbz.djvu", "navm_fgbz.dump");
841    }
842
843    #[test]
844    fn structure_colorbook() {
845        assert_structure_matches("colorbook.djvu", "colorbook.dump");
846    }
847
848    #[test]
849    fn structure_djvu3spec_bundled() {
850        assert_structure_matches("DjVu3Spec_bundled.djvu", "djvu3spec_bundled.dump");
851    }
852
853    #[test]
854    fn structure_big_scanned_page() {
855        assert_structure_matches("big-scanned-page.djvu", "big_scanned_page.dump");
856    }
857
858    // ---- emitted_size / partial_emit ----------------------------------------
859
860    /// `emitted_size(root)` must equal the bytes `emit` writes for that root
861    /// (the whole file minus the 4-byte `AT&T` magic) — the invariant DIRM
862    /// offset recomputation relies on. Checked across the real-asset corpus,
863    /// which mixes odd- and even-length FORM declarations.
864    fn assert_emitted_size_matches_emit(name: &str) {
865        let Ok(data) = std::fs::read(assets_path().join(name)) else {
866            return; // asset not vendored in this checkout
867        };
868        let file = parse(&data).unwrap();
869        let emitted = emit(&file);
870        assert_eq!(
871            emitted_size(&file.root),
872            emitted.len() - 4,
873            "emitted_size disagrees with emit() for {name}"
874        );
875    }
876
877    #[test]
878    fn emitted_size_matches_emit_corpus() {
879        for name in [
880            "boy_jb2.djvu",
881            "boy.djvu",
882            "chicken.djvu",
883            "carte.djvu",
884            "navm_fgbz.djvu",
885            "colorbook.djvu",
886            "DjVu3Spec_bundled.djvu",
887            "big-scanned-page.djvu",
888        ] {
889            assert_emitted_size_matches_emit(name);
890        }
891    }
892
893    #[test]
894    fn partial_emit_verbatim_matches_chunk_framing() {
895        // A child copied verbatim from a canonical emit must produce the same
896        // bytes as re-framing that child through EmitPart::Chunk — i.e. the
897        // byte-preserving path and the re-emit path agree. Build an even-parity
898        // tree (root length 0) so emit word-aligns every child, the convention
899        // partial_emit also uses.
900        let tree = DjvuFile {
901            root: Chunk::Form {
902                secondary_id: *b"DJVU",
903                length: 0,
904                children: vec![
905                    Chunk::Leaf {
906                        id: *b"INFO",
907                        data: vec![0xAA; 5], // odd → forces a pad
908                    },
909                    Chunk::Leaf {
910                        id: *b"Sjbz",
911                        data: vec![0xBB; 4], // even
912                    },
913                ],
914            },
915        };
916        let canonical = emit(&tree); // AT&T + FORM + DJVU + framed children
917
918        let Chunk::Form { children, .. } = &tree.root else {
919            unreachable!()
920        };
921        // Re-emit each child into its own framed block to slice verbatim spans.
922        let mut info_bytes = Vec::new();
923        emit_chunk(&children[0], &mut info_bytes);
924        let mut sjbz_bytes = Vec::new();
925        emit_chunk(&children[1], &mut sjbz_bytes);
926
927        let via_verbatim = partial_emit(
928            *b"DJVU",
929            &[
930                EmitPart::Verbatim(&info_bytes),
931                EmitPart::Verbatim(&sjbz_bytes),
932            ],
933        )
934        .expect("fits in u32");
935        let via_chunks = partial_emit(
936            *b"DJVU",
937            &[EmitPart::Chunk(&children[0]), EmitPart::Chunk(&children[1])],
938        )
939        .expect("fits in u32");
940
941        assert_eq!(via_verbatim, canonical, "verbatim path must match emit");
942        assert_eq!(via_chunks, canonical, "chunk path must match emit");
943    }
944
945    #[test]
946    fn partial_emit_pads_odd_verbatim_child() {
947        // A 3-byte verbatim child must be padded to an even boundary inside the
948        // payload, exactly like an emitted odd-length chunk.
949        let parts = [EmitPart::Verbatim(&[1u8, 2, 3])];
950        let out = partial_emit(*b"DJVU", &parts).unwrap();
951        // AT&T(4) FORM(4) len(4) DJVU(4) + 3 data + 1 pad = 20 bytes.
952        assert_eq!(out.len(), 20);
953        assert_eq!(&out[..8], b"AT&TFORM");
954        // FORM length = DJVU(4) + 3 + 1 pad = 8.
955        assert_eq!(u32::from_be_bytes(out[8..12].try_into().unwrap()), 8);
956        assert_eq!(&out[12..16], b"DJVU");
957        assert_eq!(&out[16..19], &[1, 2, 3]);
958        assert_eq!(out[19], 0);
959    }
960
961    #[test]
962    fn partial_emit_form_part_frames_nested_form() {
963        // An `EmitPart::Form` body must be framed as `FORM` + len + body + pad,
964        // identical to copying a pre-framed FORM chunk verbatim.
965        let body: &[u8] = b"DJVUxyz"; // 7 bytes (odd) → forces a pad
966        let via_form = partial_emit(*b"DJVM", &[EmitPart::Form(body)]).unwrap();
967
968        // Hand-frame the same component to compare against the seam output.
969        let mut framed = Vec::new();
970        framed.extend_from_slice(b"FORM");
971        framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
972        framed.extend_from_slice(body);
973        framed.push(0); // odd body → pad
974        let via_verbatim = partial_emit(*b"DJVM", &[EmitPart::Verbatim(&framed)]).unwrap();
975
976        assert_eq!(via_form, via_verbatim, "Form part must match framed FORM");
977        // Spot-check the literal bytes too.
978        assert_eq!(&via_form[..8], b"AT&TFORM");
979        assert_eq!(&via_form[12..16], b"DJVM");
980        assert_eq!(&via_form[16..20], b"FORM");
981        assert_eq!(u32::from_be_bytes(via_form[20..24].try_into().unwrap()), 7);
982        assert_eq!(&via_form[24..31], body);
983        assert_eq!(via_form[31], 0); // pad
984    }
985
986    #[test]
987    fn partial_emit_with_offsets_reports_part_starts() {
988        // Each reported offset must point at the byte where that part's framing
989        // begins (the `FORM`/leaf-id tag), measured from the `AT&T` magic.
990        let dirm = Chunk::Leaf {
991            id: *b"DIRM",
992            data: vec![0xAB; 5], // odd → the DIRM chunk gets a pad
993        };
994        let comp0: &[u8] = b"DJVU0000"; // 8 bytes (even)
995        let comp1: &[u8] = b"DJVIaa"; // 6 bytes (even)
996        let parts = [
997            EmitPart::Chunk(&dirm),
998            EmitPart::Form(comp0),
999            EmitPart::Form(comp1),
1000        ];
1001        let (bytes, offsets) = partial_emit_with_offsets(*b"DJVM", &parts).unwrap();
1002
1003        assert_eq!(offsets.len(), 3);
1004        // DIRM: AT&T(4)+FORM(4)+len(4)+DJVM(4) = 16.
1005        assert_eq!(offsets[0], 16);
1006        assert_eq!(&bytes[offsets[0]..offsets[0] + 4], b"DIRM");
1007        // Component FORM tags land exactly where the offset table says.
1008        for &off in &offsets[1..] {
1009            assert_eq!(&bytes[off..off + 4], b"FORM", "offset must point at FORM");
1010        }
1011        // comp1 sits after comp0's full framing: 8 (header) + 8 (even body).
1012        assert_eq!(offsets[2] - offsets[1], 16);
1013    }
1014
1015    // ---- New spec-based parser tests ----------------------------------------
1016
1017    /// Build a minimal valid single-page DjVu file in memory for testing.
1018    fn minimal_djvu_bytes() -> Vec<u8> {
1019        let info_data: &[u8] = &[
1020            0x00, 0xB5, // width = 181
1021            0x00, 0xF0, // height = 240
1022            0x18, // minor version
1023            0x00, // major version
1024            0x64, 0x00, // dpi = 100 (little-endian)
1025            0x16, // gamma byte = 22 → 2.2
1026            0x00, // flags: no rotation
1027        ];
1028        let info_len = info_data.len() as u32;
1029
1030        let mut chunk = Vec::new();
1031        chunk.extend_from_slice(b"INFO");
1032        chunk.extend_from_slice(&info_len.to_be_bytes());
1033        chunk.extend_from_slice(info_data);
1034
1035        let mut form_body = Vec::new();
1036        form_body.extend_from_slice(b"DJVU");
1037        form_body.extend_from_slice(&chunk);
1038
1039        let form_len = form_body.len() as u32;
1040
1041        let mut file = Vec::new();
1042        file.extend_from_slice(b"AT&T");
1043        file.extend_from_slice(b"FORM");
1044        file.extend_from_slice(&form_len.to_be_bytes());
1045        file.extend_from_slice(&form_body);
1046
1047        file
1048    }
1049
1050    #[test]
1051    fn empty_input_is_error() {
1052        let result = parse_form(&[]);
1053        assert!(result.is_err());
1054        assert_eq!(result.unwrap_err(), IffError::TooShort);
1055    }
1056
1057    #[test]
1058    fn short_input_is_error() {
1059        let result = parse_form(&[0u8; 10]);
1060        assert!(result.is_err());
1061        assert_eq!(result.unwrap_err(), IffError::TooShort);
1062    }
1063
1064    #[test]
1065    fn bad_magic_is_error() {
1066        let mut data = minimal_djvu_bytes();
1067        data[0] = 0xFF;
1068        data[1] = 0xFF;
1069        data[2] = 0xFF;
1070        data[3] = 0xFF;
1071
1072        let result = parse_form(&data);
1073        assert!(result.is_err());
1074        assert_eq!(
1075            result.unwrap_err(),
1076            IffError::BadMagic {
1077                got: [0xFF, 0xFF, 0xFF, 0xFF]
1078            }
1079        );
1080    }
1081
1082    #[test]
1083    fn valid_single_page_parses() {
1084        let data = minimal_djvu_bytes();
1085        let form = parse_form(&data).expect("should parse successfully");
1086
1087        assert_eq!(&form.form_type, b"DJVU");
1088        assert_eq!(form.chunks.len(), 1);
1089        assert_eq!(&form.chunks[0].id, b"INFO");
1090        assert_eq!(form.chunks[0].data.len(), 10);
1091    }
1092
1093    #[test]
1094    fn truncated_chunk_is_error() {
1095        let mut data = minimal_djvu_bytes();
1096        let new_len = data.len() - 4;
1097        data.truncate(new_len);
1098
1099        let result = parse_form(&data);
1100        assert!(result.is_err());
1101        match result.unwrap_err() {
1102            IffError::ChunkTooLong { .. } | IffError::Truncated => {}
1103            other => panic!("expected ChunkTooLong or Truncated, got {:?}", other),
1104        }
1105    }
1106
1107    #[test]
1108    fn non_form_root_chunk_is_truncated_error() {
1109        // Line 556: AT&T magic present but root chunk id is not FORM
1110        let mut data = Vec::new();
1111        data.extend_from_slice(b"AT&T");
1112        data.extend_from_slice(b"INFO"); // not FORM
1113        data.extend_from_slice(&10u32.to_be_bytes());
1114        data.extend_from_slice(&[0u8; 10]);
1115        assert_eq!(parse_form(&data).unwrap_err(), IffError::Truncated);
1116    }
1117
1118    #[test]
1119    fn form_too_short_for_secondary_id() {
1120        // Line 574: FORM length < 4 (not enough bytes for the secondary_id).
1121        // parse_form requires >= 16 bytes total, so pad to 16 while keeping length=3.
1122        let mut data = Vec::new();
1123        data.extend_from_slice(b"AT&T");
1124        data.extend_from_slice(b"FORM");
1125        data.extend_from_slice(&3u32.to_be_bytes()); // length = 3 < 4
1126        data.extend_from_slice(b"XYZ\x00"); // 4 bytes to reach 16 total
1127        assert_eq!(parse_form(&data).unwrap_err(), IffError::Truncated);
1128    }
1129
1130    #[test]
1131    fn sub_chunk_length_exceeds_body() {
1132        // Lines 608-610: a sub-chunk in parse_form_body claims more bytes than available
1133        // Build a minimal DJVU FORM: AT&T + FORM(length) + DJVU + INFO(claimed 100, actual 2)
1134        let mut body = Vec::new();
1135        body.extend_from_slice(b"DJVU"); // form_type
1136        body.extend_from_slice(b"INFO");
1137        body.extend_from_slice(&100u32.to_be_bytes()); // claimed length: 100
1138        body.extend_from_slice(&[0u8; 2]); // only 2 actual bytes
1139        let mut data = Vec::new();
1140        data.extend_from_slice(b"AT&T");
1141        data.extend_from_slice(b"FORM");
1142        data.extend_from_slice(&(body.len() as u32).to_be_bytes());
1143        data.extend_from_slice(&body);
1144        match parse_form(&data).unwrap_err() {
1145            IffError::ChunkTooLong { .. } => {}
1146            other => panic!("expected ChunkTooLong, got {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn unknown_form_type_allowed() {
1152        let mut data = minimal_djvu_bytes();
1153        data[12] = b'X';
1154        data[13] = b'X';
1155        data[14] = b'X';
1156        data[15] = b'X';
1157
1158        let form = parse_form(&data).expect("unknown form type should still parse");
1159        assert_eq!(&form.form_type, b"XXXX");
1160    }
1161
1162    #[test]
1163    fn real_chicken_djvu_parses() {
1164        let path = assets_path().join("chicken.djvu");
1165        let data = std::fs::read(&path).expect("chicken.djvu must exist");
1166        let form = parse_form(&data).expect("chicken.djvu should parse");
1167
1168        assert_eq!(&form.form_type, b"DJVU");
1169        assert!(!form.chunks.is_empty(), "must have at least one chunk");
1170        assert_eq!(&form.chunks[0].id, b"INFO");
1171        assert!(form.chunks[0].data.len() >= 10);
1172    }
1173
1174    #[test]
1175    fn real_multipage_djvu_parses() {
1176        let path = assets_path().join("navm_fgbz.djvu");
1177        let data = std::fs::read(&path).expect("navm_fgbz.djvu must exist");
1178        let form = parse_form(&data).expect("navm_fgbz.djvu should parse");
1179
1180        assert_eq!(&form.form_type, b"DJVM");
1181        assert!(!form.chunks.is_empty());
1182    }
1183
1184    // Lines 95-102: LegacyError Display variants
1185    #[test]
1186    fn legacy_error_display_variants() {
1187        assert_eq!(
1188            LegacyError::UnexpectedEof.to_string(),
1189            "unexpected end of input"
1190        );
1191        assert_eq!(
1192            LegacyError::InvalidMagic.to_string(),
1193            "invalid magic number"
1194        );
1195        assert_eq!(LegacyError::InvalidLength.to_string(), "invalid length");
1196        assert_eq!(
1197            LegacyError::MissingChunk("INFO").to_string(),
1198            "missing required chunk: INFO"
1199        );
1200        assert_eq!(LegacyError::Unsupported("x").to_string(), "unsupported: x");
1201        assert_eq!(
1202            LegacyError::FormatError("y".to_string()).to_string(),
1203            "format error: y"
1204        );
1205    }
1206
1207    // Lines 151, 169-172, 180, 185-190: Chunk accessor methods on Form/Leaf
1208    #[test]
1209    fn chunk_accessors_form_and_leaf() {
1210        let leaf = Chunk::Leaf {
1211            id: *b"INFO",
1212            data: vec![1, 2, 3],
1213        };
1214        let form = Chunk::Form {
1215            secondary_id: *b"DJVU",
1216            length: 10,
1217            children: vec![leaf.clone()],
1218        };
1219
1220        // data(): Form returns empty, Leaf returns data
1221        assert_eq!(form.data(), &[] as &[u8]);
1222        assert_eq!(leaf.data(), &[1u8, 2, 3]);
1223
1224        // children(): Form returns children, Leaf returns empty
1225        assert_eq!(form.children().len(), 1);
1226        assert!(leaf.children().is_empty());
1227
1228        // payload_length(): Form returns declared length, Leaf returns data.len()
1229        assert_eq!(form.payload_length(), 10);
1230        assert_eq!(leaf.payload_length(), 3);
1231
1232        // find_first(): on Leaf returns None (no children)
1233        assert!(leaf.find_first(b"INFO").is_none());
1234
1235        // find_first() on Form with no matching child returns None
1236        let form2 = Chunk::Form {
1237            secondary_id: *b"DJVU",
1238            length: 0,
1239            children: vec![],
1240        };
1241        assert!(form2.find_first(b"INFO").is_none());
1242    }
1243
1244    #[test]
1245    fn find_all_returns_all_matching_leaves() {
1246        let leaf1 = Chunk::Leaf {
1247            id: *b"INFO",
1248            data: vec![1],
1249        };
1250        let leaf2 = Chunk::Leaf {
1251            id: *b"INFO",
1252            data: vec![2],
1253        };
1254        let leaf3 = Chunk::Leaf {
1255            id: *b"BG44",
1256            data: vec![3],
1257        };
1258        // A Form child — find_all should skip it (the _ => false branch)
1259        let child_form = Chunk::Form {
1260            secondary_id: *b"DJVU",
1261            length: 0,
1262            children: vec![],
1263        };
1264        let form = Chunk::Form {
1265            secondary_id: *b"DJVU",
1266            length: 0,
1267            children: vec![leaf1, leaf2, leaf3, child_form],
1268        };
1269        let all_info = form.find_all(b"INFO");
1270        assert_eq!(all_info.len(), 2);
1271        let all_bg44 = form.find_all(b"BG44");
1272        assert_eq!(all_bg44.len(), 1);
1273        let all_none = form.find_all(b"NONE");
1274        assert!(all_none.is_empty());
1275    }
1276
1277    #[test]
1278    fn find_first_skips_form_children() {
1279        // A Form whose first child is itself a Form — the `_ => false` branch
1280        // in find_first skips it and finds the Leaf later.
1281        let child_form = Chunk::Form {
1282            secondary_id: *b"DJVU",
1283            length: 0,
1284            children: vec![],
1285        };
1286        let leaf = Chunk::Leaf {
1287            id: *b"INFO",
1288            data: vec![42],
1289        };
1290        let form = Chunk::Form {
1291            secondary_id: *b"DJVU",
1292            length: 0,
1293            children: vec![child_form, leaf],
1294        };
1295        let found = form.find_first(b"INFO").expect("should find INFO");
1296        assert!(matches!(found, Chunk::Leaf { id, .. } if id == b"INFO"));
1297    }
1298
1299    #[test]
1300    fn parse_empty_input_returns_unexpected_eof() {
1301        // Line 207: data.len() < 4
1302        assert!(matches!(parse(b""), Err(Error::UnexpectedEof)));
1303        assert!(matches!(parse(b"AT"), Err(Error::UnexpectedEof)));
1304    }
1305
1306    #[test]
1307    fn parse_form_length_too_small_returns_invalid_length() {
1308        // Line 255: FORM chunk with length field < 4
1309        // AT&T + FORM + length(3) + 3 bytes payload = 15 bytes total
1310        let mut data = vec![];
1311        data.extend_from_slice(b"AT&T");
1312        data.extend_from_slice(b"FORM");
1313        data.extend_from_slice(&3u32.to_be_bytes()); // length < 4
1314        data.extend_from_slice(b"XYZ");
1315        assert!(matches!(parse(&data), Err(Error::InvalidLength)));
1316    }
1317
1318    #[test]
1319    fn parse_children_skips_trailing_bytes() {
1320        // Line 295: FORM with trailing bytes (pos + 8 > end but pos < end)
1321        // Construct a FORM with 4 bytes secondary_id + 5 bytes trailing junk
1322        // (5 < 8, so parse_children will break out of its loop)
1323        let mut data = vec![];
1324        data.extend_from_slice(b"AT&T");
1325        data.extend_from_slice(b"FORM");
1326        let secondary_plus_junk = b"DJVU\x01\x02\x03\x04\x05"; // 4 + 5 = 9 bytes
1327        data.extend_from_slice(&(secondary_plus_junk.len() as u32).to_be_bytes());
1328        data.extend_from_slice(secondary_plus_junk);
1329        let result = parse(&data);
1330        // Should succeed (not error) and produce a Form with 0 children
1331        let djvu = result.expect("trailing bytes must not cause an error");
1332        assert!(matches!(djvu.root, Chunk::Form { .. }));
1333        assert!(djvu.root.children().is_empty());
1334    }
1335
1336    #[test]
1337    fn odd_length_chunk_padding() {
1338        let chunk1_data: &[u8] = &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]; // 5 bytes → padded to 6
1339        let chunk2_data: &[u8] = &[0x01, 0x02]; // 2 bytes
1340
1341        let mut form_body: Vec<u8> = Vec::new();
1342        form_body.extend_from_slice(b"DJVU");
1343
1344        form_body.extend_from_slice(b"TST1");
1345        form_body.extend_from_slice(&5u32.to_be_bytes());
1346        form_body.extend_from_slice(chunk1_data);
1347        form_body.push(0x00); // padding byte
1348
1349        form_body.extend_from_slice(b"TST2");
1350        form_body.extend_from_slice(&2u32.to_be_bytes());
1351        form_body.extend_from_slice(chunk2_data);
1352
1353        let form_len = form_body.len() as u32;
1354
1355        let mut file: Vec<u8> = Vec::new();
1356        file.extend_from_slice(b"AT&T");
1357        file.extend_from_slice(b"FORM");
1358        file.extend_from_slice(&form_len.to_be_bytes());
1359        file.extend_from_slice(&form_body);
1360
1361        let form = parse_form(&file).expect("should parse padded chunk");
1362        assert_eq!(form.chunks.len(), 2);
1363        assert_eq!(&form.chunks[0].id, b"TST1");
1364        assert_eq!(form.chunks[0].data, chunk1_data);
1365        assert_eq!(&form.chunks[1].id, b"TST2");
1366        assert_eq!(form.chunks[1].data, chunk2_data);
1367    }
1368}