Skip to main content

scll_core/cap/
mod.rs

1//! CAP-file parser — PDD §5.4a (Java Card VM Spec v3.1 Ch. 6). Pure; top fuzz
2//! target (§10.5 #1: parses an attacker-influenceable ZIP).
3//!
4//! A `.cap` is a ZIP. The parser locates the components and presents the Load
5//! File Data Block in GPCS §C.2 order: Header | Directory | Import | Applet |
6//! Class | Method | `StaticField` | Export | `ConstantPool` | `RefLocation` |
7//! Descriptor | [Debug excluded].
8//!
9//! `no_std`, streaming. The full LFDB is never materialized: [`CapFile`] borrows
10//! the input ZIP, and the LFDB is produced one [`LOAD_BLOCK_DATA`]-sized chunk
11//! at a time via [`LoadFileDataBlock::next_block`]. The caller feeds each chunk
12//! to both the LFDB hasher and the LOAD command (§5.4a) incrementally.
13//!
14//! **Compression: STORED + DEFLATE, alloc-free.** STORED entries are read
15//! directly from the borrowed input. DEFLATE entries are inflated with
16//! `miniz_oxide` (`default-features = false`, no alloc) into a caller-lent
17//! 32 KiB window ([`InflateCtx`]); the window is used as a **wrapping** LZ77
18//! dictionary ring (RFC 1951's 32 KiB max match distance), so a component of
19//! any size streams through it without heap or a full-output buffer, and host
20//! and embedded share one code path. The standard `JavaCard` converter emits
21//! DEFLATE JARs, so no host-side repack is required.
22
23mod components;
24pub use components::{AppletEntry, CapComponents};
25
26use crate::aid::Aid;
27use crate::limits::{INFLATE_WINDOW, LOAD_BLOCK_DATA};
28
29// ---- CAP component set (Java Card VM Spec v3.1 §6.1) ----------------------
30
31/// LFDB component count: the GPCS §C.2 order, `Debug.cap` excluded.
32const LFDB_COMPONENTS: usize = 11;
33
34/// Component file basenames in GPCS §C.2 / Load File Data Block order. `Debug`
35/// is intentionally omitted: it is never part of the LFDB.
36const COMPONENT_NAMES: [&[u8]; LFDB_COMPONENTS] = [
37    b"Header.cap",
38    b"Directory.cap",
39    b"Import.cap",
40    b"Applet.cap",
41    b"Class.cap",
42    b"Method.cap",
43    b"StaticField.cap",
44    b"Export.cap",
45    b"ConstantPool.cap",
46    b"RefLocation.cap",
47    b"Descriptor.cap",
48];
49
50/// Index of `Header.cap` within [`COMPONENT_NAMES`] (mandatory component).
51const IDX_HEADER: usize = 0;
52/// Index of `Import.cap`.
53const IDX_IMPORT: usize = 2;
54/// Index of `Applet.cap`.
55const IDX_APPLET: usize = 3;
56
57/// CAP/JAR component header magic (`0xDECAFFED`, JC VM Spec v3.1 §6.3).
58const HEADER_MAGIC: u32 = 0xDECA_FFED;
59
60/// ZIP compression method: stored (no compression).
61const METHOD_STORED: u16 = 0;
62/// ZIP compression method: DEFLATE.
63const METHOD_DEFLATE: u16 = 8;
64
65// ---- ZIP record locations --------------------------------------------------
66
67/// Where one CAP component's bytes live inside the borrowed ZIP, and how they
68/// are encoded. Resolved once during [`parse`]; the bulk bytes stay in the
69/// input.
70#[derive(Clone, Copy)]
71struct CompLoc {
72    /// ZIP compression method (`0` stored, `8` DEFLATE).
73    method: u16,
74    /// Offset of the file *data* (post local-header) within the ZIP.
75    data_off: usize,
76    /// Compressed (on-disk) byte length.
77    comp_size: usize,
78    /// Uncompressed byte length (the component's contribution to the LFDB).
79    uncomp_size: usize,
80}
81
82/// Parsed CAP file. Borrows the input ZIP bytes for the lifetime `'a`; the Load
83/// File Data Block is produced on demand (never owned).
84pub struct CapFile<'a> {
85    pub package_aid: Aid,
86    /// Parsed component metadata (imports, applet entries) — small owned values.
87    pub components: CapComponents,
88    /// Borrowed ZIP payload; the LFDB is streamed from here, never copied whole.
89    pub(crate) zip: &'a [u8],
90    /// Resolved component locations in §C.2 order; `None` = component absent.
91    locs: [Option<CompLoc>; LFDB_COMPONENTS],
92}
93
94impl<'a> CapFile<'a> {
95    /// A streaming view of the assembled Load File Data Block (GPCS §C.2 order).
96    /// Total length is computed up front (drives the `'C4'` TLV length and the
97    /// LOAD `block_count`); no bytes are copied/inflated until
98    /// [`LoadFileDataBlock::next_block`].
99    #[must_use]
100    pub fn lfdb(&self) -> LoadFileDataBlock<'a> {
101        let content_len = self.locs.iter().flatten().map(|c| c.uncomp_size).sum();
102        LoadFileDataBlock {
103            zip: self.zip,
104            locs: self.locs,
105            comp: 0,
106            cursor: CompCursor::new(),
107            header: LfdbHeader::new(content_len),
108        }
109    }
110}
111
112/// Caller-owned DEFLATE working set for compressed components: the 32 KiB
113/// wrapping window ([`INFLATE_WINDOW`]) used as the LZ77 dictionary ring, plus
114/// `miniz_oxide`'s decompressor state. Allocate once (it is large — the window
115/// plus a multi-KB Huffman-table state struct; place it in a `static` or on a
116/// generous stack) and lend it to [`LoadFileDataBlock::next_block`]. It is
117/// untouched while a STORED component is being read.
118pub struct InflateCtx {
119    window: [u8; INFLATE_WINDOW],
120    state: miniz_oxide::inflate::core::DecompressorOxide,
121}
122
123impl Default for InflateCtx {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl InflateCtx {
130    /// Construct a fresh inflate context (zeroed window, reset decompressor).
131    #[must_use]
132    #[expect(
133        clippy::large_stack_arrays,
134        reason = "32 KiB inflate window is intentional; the alloc-free no_std design lends it from a static or generous stack (PDD §5.4a) — heap allocation is unavailable"
135    )]
136    pub fn new() -> Self {
137        Self {
138            window: [0u8; INFLATE_WINDOW],
139            state: miniz_oxide::inflate::core::DecompressorOxide::new(),
140        }
141    }
142
143    /// Reset between components (clears the decompressor; the window is reused).
144    pub fn reset(&mut self) {
145        self.state = miniz_oxide::inflate::core::DecompressorOxide::new();
146    }
147}
148
149/// Per-component streaming progress. For STORED, only `emitted` advances; for
150/// DEFLATE, the ring bookkeeping (`produced`/`emitted`/`in_pos`/`done`) drives
151/// the wrapping-window inflate.
152#[derive(Clone, Copy)]
153struct CompCursor {
154    /// Bytes of compressed input consumed (DEFLATE only).
155    in_pos: usize,
156    /// Total decompressed bytes written into the ring (DEFLATE), or bytes
157    /// copied from the STORED slice.
158    produced: usize,
159    /// Total bytes handed to the consumer for this component.
160    emitted: usize,
161    /// DEFLATE stream reported `Done`.
162    done: bool,
163    /// `state`/window have been reset for this (DEFLATE) component.
164    started: bool,
165}
166
167impl CompCursor {
168    const fn new() -> Self {
169        Self {
170            in_pos: 0,
171            produced: 0,
172            emitted: 0,
173            done: false,
174            started: false,
175        }
176    }
177}
178
179/// The `'C4'` Load File Data Block tag plus its BER length, streamed ahead of
180/// the §C.2-ordered components (GPCS v2.3.1 §11.6.2.3 / Table 11-58). The card
181/// expects the LOAD data field to begin `C4 ‖ len ‖ <load file>`, so this
182/// header is emitted as the first bytes of the LFDB byte stream. It is tiny
183/// (2–5 bytes) and may straddle into the first LOAD block alongside component
184/// bytes. The length is the **content** length (sum of the components); it does
185/// not count the header itself.
186#[derive(Clone, Copy)]
187struct LfdbHeader {
188    buf: [u8; 5],
189    len: u8,
190    emitted: u8,
191}
192
193impl LfdbHeader {
194    /// Build `C4 ‖ BER-length(content_len)`. BER definite length: a single byte
195    /// for `< 0x80`, else `0x8N` followed by `N` big-endian length bytes
196    /// (ISO/IEC 8825-1 / GPCS §11.1.5). A LOAD payload never exceeds the
197    /// 256-block × short-APDU budget, so three length bytes (`0x83 …`) is the
198    /// most that can occur; the 5-byte buffer covers it.
199    #[allow(clippy::cast_possible_truncation)] // each byte is an explicit 8-bit slice of content_len
200    const fn new(content_len: usize) -> Self {
201        let mut buf = [0u8; 5];
202        buf[0] = 0xC4;
203        let len: u8 = if content_len < 0x80 {
204            buf[1] = content_len as u8;
205            2
206        } else if content_len <= 0xFF {
207            buf[1] = 0x81;
208            buf[2] = content_len as u8;
209            3
210        } else if content_len <= 0xFFFF {
211            buf[1] = 0x82;
212            buf[2] = (content_len >> 8) as u8;
213            buf[3] = content_len as u8;
214            4
215        } else {
216            buf[1] = 0x83;
217            buf[2] = (content_len >> 16) as u8;
218            buf[3] = (content_len >> 8) as u8;
219            buf[4] = content_len as u8;
220            5
221        };
222        Self {
223            buf,
224            len,
225            emitted: 0,
226        }
227    }
228
229    /// Header bytes not yet handed to the consumer.
230    const fn remaining(self) -> usize {
231        (self.len - self.emitted) as usize
232    }
233
234    /// Copy as many remaining header bytes as fit into `out`; return the count.
235    #[allow(clippy::cast_possible_truncation)] // `take` is bounded by `len` (≤ 5)
236    fn emit(&mut self, out: &mut [u8]) -> usize {
237        let from = self.emitted as usize;
238        let take = self.remaining().min(out.len());
239        out[..take].copy_from_slice(&self.buf[from..from + take]);
240        self.emitted += take as u8;
241        take
242    }
243
244    /// Restart header emission (paired with [`LoadFileDataBlock::reset`]).
245    fn reset(&mut self) {
246        self.emitted = 0;
247    }
248}
249
250/// Streaming Load File Data Block. Stateful pull reader: each call to
251/// [`Self::next_block`] writes the next chunk into the caller's buffer, so the
252/// whole block never resides in RAM at once.
253pub struct LoadFileDataBlock<'a> {
254    zip: &'a [u8],
255    locs: [Option<CompLoc>; LFDB_COMPONENTS],
256    /// Index into `locs` of the component currently being emitted.
257    comp: usize,
258    cursor: CompCursor,
259    /// `'C4'` tag + BER length, streamed before the first component.
260    header: LfdbHeader,
261}
262
263impl LoadFileDataBlock<'_> {
264    /// Total LFDB byte length (sum of the §C.2-ordered components, decompressed).
265    /// Needed for the `'C4'` tag length and to derive `block_count`.
266    /// Total **framed** LFDB byte length streamed to LOAD: the `'C4'` tag+length
267    /// header plus the §C.2-ordered component content. This is what the LOAD
268    /// loop counts for last-block detection and `block_count`.
269    #[must_use]
270    pub fn len(&self) -> usize {
271        self.header.len as usize + self.content_len()
272    }
273
274    /// Content length only — the value of the `'C4'` TLV (sum of the §C.2
275    /// components, decompressed), excluding the header. This is the length
276    /// encoded in the `'C4'` header and the input over which a Load File Data
277    /// Block Hash (Lh) would be computed (GPCS v2.3.1 §11.6.2.3) — not the
278    /// framed stream.
279    #[must_use]
280    pub fn content_len(&self) -> usize {
281        self.locs.iter().flatten().map(|c| c.uncomp_size).sum()
282    }
283
284    /// `true` if there is no component content (degenerate / malformed CAP).
285    #[must_use]
286    pub fn is_empty(&self) -> bool {
287        self.content_len() == 0
288    }
289
290    /// Write the next LFDB chunk into `out` (should be [`LOAD_BLOCK_DATA`] long)
291    /// and return the byte count written. Returns `Ok(0)` once the block is
292    /// exhausted. STORED components are copied from the borrowed input; DEFLATE
293    /// components are inflated through `infl` (the 32 KiB window doubles as the
294    /// wrapping dictionary). A chunk may straddle component boundaries (the LFDB
295    /// is the concatenation in GPCS §C.2 order), so bytes are assembled into
296    /// `out`.
297    ///
298    /// # Errors
299    /// Returns [`CapError::Inflate`] if a DEFLATE component cannot be
300    /// decompressed (corrupt stream, or the input/window bound was exceeded).
301    pub fn next_block(&mut self, infl: &mut InflateCtx, out: &mut [u8]) -> Result<usize, CapError> {
302        let mut written = 0;
303        // The `'C4'` tag+length header is streamed ahead of the components; it
304        // may share the first block with component bytes.
305        if self.header.remaining() > 0 {
306            written += self.header.emit(out);
307        }
308        while written < out.len() {
309            // Advance past absent components and components fully emitted.
310            let Some(loc) = self.current_loc() else {
311                if self.comp >= LFDB_COMPONENTS {
312                    break; // LFDB exhausted
313                }
314                self.advance();
315                continue;
316            };
317            let n = if loc.method == METHOD_DEFLATE {
318                self.emit_deflate(&loc, infl, &mut out[written..])?
319            } else {
320                self.emit_stored(&loc, &mut out[written..])
321            };
322            written += n;
323            if self.component_exhausted(&loc) {
324                self.advance();
325            } else if n == 0 {
326                // No progress with space remaining ⇒ guard against a stall.
327                break;
328            }
329        }
330        Ok(written)
331    }
332
333    /// Restart from the first byte (e.g. to hash the LFDB, then re-stream it to
334    /// LOAD without re-parsing the ZIP). Caller should also `infl.reset()`.
335    pub fn reset(&mut self) {
336        self.comp = 0;
337        self.cursor = CompCursor::new();
338        self.header.reset();
339    }
340
341    /// The location of the component currently being emitted, if present and
342    /// not yet exhausted.
343    fn current_loc(&self) -> Option<CompLoc> {
344        self.locs.get(self.comp).copied().flatten()
345    }
346
347    /// Move to the next component, resetting per-component progress.
348    fn advance(&mut self) {
349        self.comp += 1;
350        self.cursor = CompCursor::new();
351    }
352
353    /// `true` once every decompressed/stored byte of `loc` has been emitted.
354    fn component_exhausted(&self, loc: &CompLoc) -> bool {
355        self.cursor.emitted >= loc.uncomp_size
356    }
357
358    /// Copy the next slice of a STORED component straight from the ZIP.
359    fn emit_stored(&mut self, loc: &CompLoc, out: &mut [u8]) -> usize {
360        let start = loc.data_off + self.cursor.emitted;
361        let remaining = loc.uncomp_size - self.cursor.emitted;
362        let take = remaining
363            .min(out.len())
364            .min(self.zip.len().saturating_sub(start));
365        out[..take].copy_from_slice(&self.zip[start..start + take]);
366        self.cursor.emitted += take;
367        take
368    }
369
370    /// Inflate the next slice of a DEFLATE component through the wrapping ring.
371    fn emit_deflate(
372        &mut self,
373        loc: &CompLoc,
374        infl: &mut InflateCtx,
375        out: &mut [u8],
376    ) -> Result<usize, CapError> {
377        if !self.cursor.started {
378            infl.reset();
379            self.cursor.started = true;
380        }
381        let mut w = 0;
382        while w < out.len() {
383            let pending = self.cursor.produced - self.cursor.emitted;
384            if pending == 0 {
385                if self.cursor.done {
386                    break;
387                }
388                self.pump(loc, infl)?;
389                if self.cursor.produced == self.cursor.emitted && self.cursor.done {
390                    break;
391                }
392                continue;
393            }
394            let mask = INFLATE_WINDOW - 1;
395            let start = self.cursor.emitted & mask;
396            let take = pending.min(out.len() - w).min(INFLATE_WINDOW - start);
397            out[w..w + take].copy_from_slice(&infl.window[start..start + take]);
398            self.cursor.emitted += take;
399            w += take;
400        }
401        Ok(w)
402    }
403
404    /// Drive `miniz_oxide` once, appending output into the wrapping window.
405    fn pump(&mut self, loc: &CompLoc, infl: &mut InflateCtx) -> Result<(), CapError> {
406        use miniz_oxide::inflate::core::decompress;
407        use miniz_oxide::inflate::TINFLStatus;
408
409        let comp_end = loc.data_off + loc.comp_size;
410        if comp_end > self.zip.len() || loc.data_off + self.cursor.in_pos > comp_end {
411            return Err(CapError::Inflate);
412        }
413        let input = &self.zip[loc.data_off + self.cursor.in_pos..comp_end];
414        let ring_pos = self.cursor.produced & (INFLATE_WINDOW - 1);
415        // Raw DEFLATE (no zlib header), whole remaining input available.
416        let (status, in_consumed, out_written) =
417            decompress(&mut infl.state, input, &mut infl.window, ring_pos, 0);
418        self.cursor.in_pos += in_consumed;
419        self.cursor.produced += out_written;
420        match status {
421            TINFLStatus::Done => {
422                self.cursor.done = true;
423                Ok(())
424            }
425            TINFLStatus::HasMoreOutput | TINFLStatus::NeedsMoreInput => {
426                if in_consumed == 0 && out_written == 0 {
427                    // No forward progress with input remaining ⇒ corrupt stream.
428                    Err(CapError::Inflate)
429                } else {
430                    Ok(())
431                }
432            }
433            _ => Err(CapError::Inflate),
434        }
435    }
436}
437
438/// Parse a `.cap` (ZIP) byte buffer. Borrows `cap_zip` for the returned
439/// `CapFile`'s lifetime. STORED and DEFLATE entries are both accepted.
440///
441/// `infl` is the caller-lent 32 KiB inflate context: `parse` uses it to
442/// decompress the (small) Header/Import/Applet metadata components when they
443/// are DEFLATE-encoded, so the same buffer later serves [`CapFile::lfdb`]
444/// streaming — no second large allocation. It is left reset on return.
445///
446/// # Errors
447/// Returns [`CapError::NotAZip`] if `cap_zip` is not a ZIP,
448/// [`CapError::MissingComponent`] / [`CapError::Malformed`] for a structurally
449/// invalid CAP, or [`CapError::Inflate`] if a DEFLATE metadata component cannot
450/// be decompressed.
451pub fn parse<'a>(cap_zip: &'a [u8], infl: &mut InflateCtx) -> Result<CapFile<'a>, CapError> {
452    let locs = walk_zip(cap_zip)?;
453
454    let header_loc = locs[IDX_HEADER].ok_or(CapError::MissingComponent("Header.cap"))?;
455    // Metadata components are small; materialize each (in turn) into the lent
456    // window and parse it before reading the next — no second large allocation.
457    let header = read_component(cap_zip, &header_loc, infl)?;
458    let (package_aid, jc_platform_version) = parse_header(header)?;
459
460    let mut components = CapComponents {
461        jc_platform_version,
462        imports: heapless::Vec::new(),
463        applets: heapless::Vec::new(),
464    };
465
466    if let Some(import_loc) = locs[IDX_IMPORT] {
467        let bytes = read_component(cap_zip, &import_loc, infl)?;
468        parse_imports(bytes, &mut components)?;
469    }
470    if let Some(applet_loc) = locs[IDX_APPLET] {
471        let bytes = read_component(cap_zip, &applet_loc, infl)?;
472        parse_applets(bytes, &mut components)?;
473    }
474
475    infl.reset();
476    Ok(CapFile {
477        package_aid,
478        components,
479        zip: cap_zip,
480        locs,
481    })
482}
483
484/// Materialize one (small, metadata) component into the lent window and return
485/// the resulting slice. STORED components are copied in; DEFLATE components are
486/// inflated with the non-wrapping flag (a metadata component fits the window by
487/// construction — one that would exceed it is reported as
488/// [`CapError::Inflate`]). The borrow of `infl` ends when the returned slice is
489/// dropped, so the caller parses one component fully before reading the next.
490fn read_component<'a>(
491    zip: &[u8],
492    loc: &CompLoc,
493    infl: &'a mut InflateCtx,
494) -> Result<&'a [u8], CapError> {
495    use miniz_oxide::inflate::core::decompress;
496    use miniz_oxide::inflate::core::inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF;
497    use miniz_oxide::inflate::TINFLStatus;
498
499    let end = loc
500        .data_off
501        .checked_add(loc.comp_size)
502        .ok_or(CapError::Malformed)?;
503    if end > zip.len() {
504        return Err(CapError::Malformed);
505    }
506    let input = &zip[loc.data_off..end];
507    match loc.method {
508        METHOD_STORED => {
509            if input.len() > infl.window.len() {
510                return Err(CapError::Malformed);
511            }
512            infl.window[..input.len()].copy_from_slice(input);
513            Ok(&infl.window[..input.len()])
514        }
515        METHOD_DEFLATE => {
516            infl.reset();
517            let (status, _in, written) = decompress(
518                &mut infl.state,
519                input,
520                &mut infl.window,
521                0,
522                TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF,
523            );
524            match status {
525                TINFLStatus::Done => Ok(&infl.window[..written]),
526                // HasMoreOutput here means the component exceeds the window: out
527                // of our metadata bound — treat as malformed rather than guess.
528                _ => Err(CapError::Inflate),
529            }
530        }
531        _ => Err(CapError::Malformed),
532    }
533}
534
535// ---- ZIP central-directory walk -------------------------------------------
536
537/// End Of Central Directory signature.
538const SIG_EOCD: u32 = 0x0605_4b50;
539/// Central directory file-header signature.
540const SIG_CDH: u32 = 0x0201_4b50;
541/// Local file-header signature.
542const SIG_LFH: u32 = 0x0403_4b50;
543/// Minimum EOCD record length (no comment).
544const EOCD_MIN: usize = 22;
545
546/// Resolve every recognised CAP component to its byte location in the ZIP.
547fn walk_zip(zip: &[u8]) -> Result<[Option<CompLoc>; LFDB_COMPONENTS], CapError> {
548    let eocd = find_eocd(zip).ok_or(CapError::NotAZip)?;
549    let total = usize::from(u16::from_le_bytes([zip[eocd + 10], zip[eocd + 11]]));
550    let cd_off = read_u32(zip, eocd + 16).ok_or(CapError::Malformed)? as usize;
551
552    let mut locs: [Option<CompLoc>; LFDB_COMPONENTS] = [None; LFDB_COMPONENTS];
553    let mut pos = cd_off;
554    for _ in 0..total {
555        if read_u32(zip, pos) != Some(SIG_CDH) {
556            return Err(CapError::Malformed);
557        }
558        let method = read_u16(zip, pos + 10).ok_or(CapError::Malformed)?;
559        let comp_size = read_u32(zip, pos + 20).ok_or(CapError::Malformed)? as usize;
560        let uncomp_size = read_u32(zip, pos + 24).ok_or(CapError::Malformed)? as usize;
561        let name_len = usize::from(read_u16(zip, pos + 28).ok_or(CapError::Malformed)?);
562        let extra_len = usize::from(read_u16(zip, pos + 30).ok_or(CapError::Malformed)?);
563        let comment_len = usize::from(read_u16(zip, pos + 32).ok_or(CapError::Malformed)?);
564        let local_off = read_u32(zip, pos + 42).ok_or(CapError::Malformed)? as usize;
565
566        let name_start = pos + 46;
567        let name_end = name_start
568            .checked_add(name_len)
569            .ok_or(CapError::Malformed)?;
570        if name_end > zip.len() {
571            return Err(CapError::Malformed);
572        }
573        let name = &zip[name_start..name_end];
574
575        if let Some(idx) = component_index(name) {
576            if locs[idx].is_none() {
577                let data_off = local_data_offset(zip, local_off)?;
578                locs[idx] = Some(CompLoc {
579                    method,
580                    data_off,
581                    comp_size,
582                    uncomp_size,
583                });
584            }
585        }
586
587        pos = name_end
588            .checked_add(extra_len)
589            .and_then(|p| p.checked_add(comment_len))
590            .ok_or(CapError::Malformed)?;
591    }
592    Ok(locs)
593}
594
595/// Scan backwards for the EOCD signature, returning its offset.
596fn find_eocd(zip: &[u8]) -> Option<usize> {
597    if zip.len() < EOCD_MIN {
598        return None;
599    }
600    let max_back = zip.len() - EOCD_MIN;
601    // The comment can be up to 64 KiB; bound the scan accordingly.
602    let limit = max_back.saturating_sub(0xFFFF);
603    let mut i = max_back;
604    loop {
605        if read_u32(zip, i) == Some(SIG_EOCD) {
606            return Some(i);
607        }
608        if i == 0 || i == limit {
609            return None;
610        }
611        i -= 1;
612    }
613}
614
615/// Compute the offset of file data given a local-file-header offset.
616fn local_data_offset(zip: &[u8], local_off: usize) -> Result<usize, CapError> {
617    if read_u32(zip, local_off) != Some(SIG_LFH) {
618        return Err(CapError::Malformed);
619    }
620    let name_len = usize::from(read_u16(zip, local_off + 26).ok_or(CapError::Malformed)?);
621    let extra_len = usize::from(read_u16(zip, local_off + 28).ok_or(CapError::Malformed)?);
622    local_off
623        .checked_add(30)
624        .and_then(|p| p.checked_add(name_len))
625        .and_then(|p| p.checked_add(extra_len))
626        .filter(|&p| p <= zip.len())
627        .ok_or(CapError::Malformed)
628}
629
630/// Map a ZIP entry name to its index in [`COMPONENT_NAMES`] by basename.
631fn component_index(name: &[u8]) -> Option<usize> {
632    let base = match name.iter().rposition(|&b| b == b'/' || b == b'\\') {
633        Some(i) => &name[i + 1..],
634        None => name,
635    };
636    COMPONENT_NAMES.iter().position(|&n| n == base)
637}
638
639// ---- Component metadata parsers (JC VM Spec v3.1 Ch. 6) -------------------
640
641/// Parse `Header.cap`: validate magic and extract the package AID plus the CAP
642/// format version (recorded as `jc_platform_version`; see manifest note).
643fn parse_header(b: &[u8]) -> Result<(Aid, (u8, u8, u8)), CapError> {
644    // tag(1) size(2) magic(4) minor(1) major(1) flags(1) pkg{minor major len aid}
645    let magic = read_u32_be(b, 3).ok_or(CapError::Malformed)?;
646    if magic != HEADER_MAGIC {
647        return Err(CapError::Malformed);
648    }
649    let minor = *b.get(7).ok_or(CapError::Malformed)?;
650    let major = *b.get(8).ok_or(CapError::Malformed)?;
651    let aid_len = usize::from(*b.get(12).ok_or(CapError::Malformed)?);
652    let aid_start = 13usize;
653    let aid_end = aid_start.checked_add(aid_len).ok_or(CapError::Malformed)?;
654    let aid_bytes = b.get(aid_start..aid_end).ok_or(CapError::Malformed)?;
655    let aid = Aid::new(aid_bytes).map_err(|_| CapError::Malformed)?;
656    Ok((aid, (major, minor, 0)))
657}
658
659/// Parse `Import.cap`: a count followed by `package_info` entries; record each
660/// imported package AID.
661fn parse_imports(b: &[u8], out: &mut CapComponents) -> Result<(), CapError> {
662    // tag(1) size(2) count(1) then count * {minor(1) major(1) len(1) aid[len]}
663    let count = usize::from(*b.get(3).ok_or(CapError::Malformed)?);
664    let mut p = 4;
665    for _ in 0..count {
666        let len = usize::from(*b.get(p + 2).ok_or(CapError::Malformed)?);
667        let aid_start = p + 3;
668        let aid_end = aid_start.checked_add(len).ok_or(CapError::Malformed)?;
669        let aid_bytes = b.get(aid_start..aid_end).ok_or(CapError::Malformed)?;
670        let aid = Aid::new(aid_bytes).map_err(|_| CapError::Malformed)?;
671        out.imports.push(aid).map_err(|_| CapError::Malformed)?;
672        p = aid_end;
673    }
674    Ok(())
675}
676
677/// Parse `Applet.cap`: a count followed by `{ AID, install_method_offset }`
678/// entries.
679fn parse_applets(b: &[u8], out: &mut CapComponents) -> Result<(), CapError> {
680    // tag(1) size(2) count(1) then count * {len(1) aid[len] install_offset(2 BE)}
681    let count = usize::from(*b.get(3).ok_or(CapError::Malformed)?);
682    let mut p = 4;
683    for _ in 0..count {
684        let len = usize::from(*b.get(p).ok_or(CapError::Malformed)?);
685        let aid_start = p + 1;
686        let aid_end = aid_start.checked_add(len).ok_or(CapError::Malformed)?;
687        let aid_bytes = b.get(aid_start..aid_end).ok_or(CapError::Malformed)?;
688        let aid = Aid::new(aid_bytes).map_err(|_| CapError::Malformed)?;
689        let install_method_offset = read_u16_be(b, aid_end).ok_or(CapError::Malformed)?;
690        out.applets
691            .push(AppletEntry {
692                class_aid: aid,
693                install_method_offset,
694            })
695            .map_err(|_| CapError::Malformed)?;
696        p = aid_end + 2;
697    }
698    Ok(())
699}
700
701// ---- Bounds-checked field readers -----------------------------------------
702
703fn read_u16(b: &[u8], at: usize) -> Option<u16> {
704    let s = b.get(at..at + 2)?;
705    Some(u16::from_le_bytes([s[0], s[1]]))
706}
707
708fn read_u32(b: &[u8], at: usize) -> Option<u32> {
709    let s = b.get(at..at + 4)?;
710    Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
711}
712
713fn read_u16_be(b: &[u8], at: usize) -> Option<u16> {
714    let s = b.get(at..at + 2)?;
715    Some(u16::from_be_bytes([s[0], s[1]]))
716}
717
718fn read_u32_be(b: &[u8], at: usize) -> Option<u32> {
719    let s = b.get(at..at + 4)?;
720    Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
721}
722
723/// CAP parse failure.
724#[derive(thiserror::Error, Debug)]
725#[non_exhaustive]
726pub enum CapError {
727    #[error("input is not a ZIP")]
728    NotAZip,
729    #[error("missing CAP component: {0}")]
730    MissingComponent(&'static str),
731    #[error("malformed CAP structure")]
732    Malformed,
733    /// DEFLATE decompression of a component failed (corrupt stream, or the
734    /// window/output bound was exceeded).
735    #[error("CAP component inflate failed")]
736    Inflate,
737}
738
739// Keep LOAD_BLOCK_DATA referenced so the intended chunk size is documented at
740// the API boundary; next_block's `out` should be this long.
741const _: usize = LOAD_BLOCK_DATA;
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    // Real ZIP fixtures emitted by Python `zipfile` (paths prefixed
748    // `p/javacard/`, with a `Debug.cap` present to confirm LFDB exclusion).
749    const STORED: &[u8] = include_bytes!("testdata/minimal_stored.cap");
750    const DEFLATE: &[u8] = include_bytes!("testdata/streaming_deflate.cap");
751
752    // Component bytes baked into the fixtures (see testdata generator).
753    const PKG_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x00, 0x62, 0x03, 0x01];
754    const IMPORT_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x00, 0x62, 0x01, 0x01];
755    const APPLET_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x00, 0x62, 0x03, 0x01, 0x0A];
756
757    fn stream_all(cf: &CapFile<'_>, infl: &mut InflateCtx) -> (usize, std::vec::Vec<u8>) {
758        let mut s = cf.lfdb();
759        let total = s.len();
760        let mut out: std::vec::Vec<u8> = std::vec::Vec::new();
761        let mut buf = [0u8; LOAD_BLOCK_DATA];
762        loop {
763            let n = s.next_block(infl, &mut buf).expect("inflate ok");
764            if n == 0 {
765                break;
766            }
767            out.extend_from_slice(&buf[..n]);
768        }
769        (total, out)
770    }
771
772    fn assert_metadata(cf: &CapFile<'_>) {
773        assert_eq!(cf.package_aid.as_bytes(), PKG_AID);
774        assert_eq!(cf.components.jc_platform_version, (2, 1, 0));
775        assert_eq!(cf.components.imports.len(), 1);
776        assert_eq!(cf.components.imports[0].as_bytes(), IMPORT_AID);
777        assert_eq!(cf.components.applets.len(), 1);
778        assert_eq!(cf.components.applets[0].class_aid.as_bytes(), APPLET_AID);
779        assert_eq!(cf.components.applets[0].install_method_offset, 0x001F);
780    }
781
782    #[test]
783    fn parses_stored_metadata() {
784        let mut infl = InflateCtx::new();
785        let cf = parse(STORED, &mut infl).expect("parse stored");
786        assert_metadata(&cf);
787    }
788
789    #[test]
790    fn parses_deflate_metadata() {
791        let mut infl = InflateCtx::new();
792        let cf = parse(DEFLATE, &mut infl).expect("parse deflate");
793        assert_metadata(&cf);
794    }
795
796    fn header_len(content_len: usize) -> usize {
797        LfdbHeader::new(content_len).len as usize
798    }
799
800    #[test]
801    fn stored_lfdb_is_concatenation_in_c2_order() {
802        // Header(20) Dir(3) Import(14) Applet(15) Class(2) Method(4)
803        // StaticField(2) Export(2) ConstantPool(2) RefLocation(2) Descriptor(2).
804        let mut infl = InflateCtx::new();
805        let cf = parse(STORED, &mut infl).expect("parse");
806        let (total, out) = stream_all(&cf, &mut infl);
807        assert_eq!(total, out.len());
808        let content = 20 + 3 + 14 + 15 + 2 + 4 + 2 + 2 + 2 + 2 + 2;
809        let header = LfdbHeader::new(content);
810        let hdr = header.len as usize;
811        // Stream is `'C4' ‖ len ‖ <components>`; content is 68 B (< 0x80) so the
812        // length is a single byte → header is `C4 44`.
813        assert_eq!(total, hdr + content);
814        assert_eq!(&out[..hdr], &header.buf[..hdr]);
815        // First component is Header.cap → its magic is at content offset 3,
816        // i.e. stream offset `hdr + 3`.
817        assert_eq!(&out[hdr + 3..hdr + 7], &[0xDE, 0xCA, 0xFF, 0xED]);
818        // Debug.cap content must never appear in the LFDB.
819        assert!(!out.windows(3).any(|w| w == b"DBG"));
820    }
821
822    #[test]
823    fn deflate_lfdb_streams_oversized_component_through_ring() {
824        // Method.cap is ~52 KiB (> the 32 KiB window): exercises the wrapping
825        // ring path. Rebuild the expected block deterministically.
826        let big = {
827            let mut v = std::vec::Vec::new();
828            while v.len() < 52_000 {
829                v.extend_from_slice(b"METHOD-BYTES-");
830            }
831            v.truncate(52_000);
832            v
833        };
834        let mut infl = InflateCtx::new();
835        let cf = parse(DEFLATE, &mut infl).expect("parse");
836        let (total, out) = stream_all(&cf, &mut infl);
837        assert_eq!(total, out.len());
838        // Header(20)+Dir(3)+Import(14)+Applet(15)+Class(2)+Method(52000)
839        //   +StaticField(2)+Export(2)+ConstantPool(2)+RefLocation(2)+Descriptor(2)
840        let method_start_content = 20 + 3 + 14 + 15 + 2;
841        let content = method_start_content + big.len() + 2 + 2 + 2 + 2 + 2;
842        let hdr = header_len(content);
843        assert_eq!(total, hdr + content);
844        // Method bytes begin after the `'C4'` header in the framed stream.
845        let method_start = hdr + method_start_content;
846        assert_eq!(&out[method_start..method_start + big.len()], &big[..]);
847    }
848
849    #[test]
850    fn lfdb_reset_re_streams_identically() {
851        let mut infl = InflateCtx::new();
852        let cf = parse(STORED, &mut infl).expect("parse");
853        let mut s = cf.lfdb();
854        let mut buf = [0u8; LOAD_BLOCK_DATA];
855        let first = s.next_block(&mut infl, &mut buf).expect("ok");
856        let head_a = buf[..first].to_vec();
857        s.reset();
858        infl.reset();
859        let second = s.next_block(&mut infl, &mut buf).expect("ok");
860        assert_eq!(first, second);
861        assert_eq!(head_a.as_slice(), &buf[..second]);
862    }
863
864    #[test]
865    fn not_a_zip_is_rejected() {
866        let mut infl = InflateCtx::new();
867        assert!(matches!(
868            parse(b"definitely not a zip", &mut infl),
869            Err(CapError::NotAZip)
870        ));
871    }
872
873    #[test]
874    fn empty_input_is_rejected() {
875        let mut infl = InflateCtx::new();
876        assert!(matches!(parse(&[], &mut infl), Err(CapError::NotAZip)));
877    }
878
879    #[test]
880    fn missing_header_component_is_reported() {
881        // Truncate the stored fixture's central directory name "Header.cap" by
882        // flipping the first byte of every 'H' so no component matches Header.
883        // Simpler: build via mutation — corrupt the Header.cap basename.
884        let mut z = STORED.to_vec();
885        // Replace the first occurrence of b"Header.cap" with b"Xeader.cap".
886        if let Some(p) = z.windows(10).position(|w| w == b"Header.cap") {
887            z[p] = b'X';
888            // It appears twice (local + central header); flip both.
889            if let Some(p2) = z[p + 1..].windows(10).position(|w| w == b"Header.cap") {
890                z[p + 1 + p2] = b'X';
891            }
892        }
893        let mut infl = InflateCtx::new();
894        assert!(matches!(
895            parse(&z, &mut infl),
896            Err(CapError::MissingComponent("Header.cap"))
897        ));
898    }
899
900    #[test]
901    fn truncated_zip_does_not_panic() {
902        let mut infl = InflateCtx::new();
903        for cut in [1usize, 5, 22, 40, 100, 200] {
904            let n = cut.min(STORED.len());
905            let _ = parse(&STORED[..n], &mut infl); // must not panic
906        }
907    }
908
909    #[test]
910    fn corrupt_deflate_stream_errors_cleanly() {
911        // Flip bytes in the compressed Method.cap region: inflate must fail with
912        // a typed error rather than panic. Mutate a mid-file byte and re-parse;
913        // streaming the LFDB should surface CapError::Inflate (or parse rejects).
914        let mut z = DEFLATE.to_vec();
915        let mid = z.len() / 2;
916        z[mid] ^= 0xFF;
917        z[mid + 1] ^= 0xFF;
918        let mut infl = InflateCtx::new();
919        // Parse may reject outright (also fine); if it accepts, draining the
920        // LFDB must surface the corruption as an error, never a panic.
921        if let Ok(cf) = parse(&z, &mut infl) {
922            let mut s = cf.lfdb();
923            let mut buf = [0u8; LOAD_BLOCK_DATA];
924            while let Ok(n) = s.next_block(&mut infl, &mut buf) {
925                if n == 0 {
926                    break; // exhausted without error
927                }
928            }
929        }
930    }
931
932    #[test]
933    fn component_index_matches_basename_only() {
934        assert_eq!(component_index(b"p/javacard/Header.cap"), Some(IDX_HEADER));
935        assert_eq!(component_index(b"Import.cap"), Some(IDX_IMPORT));
936        assert_eq!(component_index(b"Applet.cap"), Some(IDX_APPLET));
937        assert_eq!(component_index(b"Debug.cap"), None); // excluded from LFDB
938        assert_eq!(component_index(b"NotMyHeader.cap"), None);
939        assert_eq!(component_index(b"weird\\Class.cap"), Some(4));
940    }
941}