Skip to main content

apr_format/v2/
reader_impl.rs

1//! v2 reader impls + shard manifest (issue #2231).
2//!
3//! Formerly `include!`d into `v2/mod.rs`; now a real module. The `AprV2Reader`
4//! and `AprV2ReaderRef` struct declarations live here next to their `impl`s
5//! (private fields).
6//!
7//! # Sovereignty seam (issue #2231)
8//!
9//! The dequantizing `get_tensor_as_f32` accessor (which needed the GGUF Q4_K /
10//! Q6_K dequant kernels + the local f16-scaled `dequantize_q4`) is **severed**
11//! from the leaf: it pulls quantization/physics that belongs to the framework.
12//! The leaf exposes only the raw container bytes ([`AprV2Reader::get_tensor_data`])
13//! and the trivial F32-only typed view ([`AprV2Reader::get_f32_tensor`]).
14//! `aprender-core` re-attaches the dequantizing accessor as the `AprV2DequantExt`
15//! extension trait.
16
17use super::{
18    is_aligned_64, AprV2Header, AprV2Metadata, TensorDType, TensorIndexEntry, V2FormatError,
19    HEADER_SIZE_V2,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::io::Read;
24
25/// APR v2 format reader (owns data - copies input)
26#[derive(Debug)]
27pub struct AprV2Reader {
28    header: AprV2Header,
29    metadata: AprV2Metadata,
30    tensor_index: Vec<TensorIndexEntry>,
31    data: Vec<u8>,
32}
33
34/// APR v2 format reader with zero-copy (borrows data - for mmap)
35///
36/// This reader borrows the data slice instead of copying it, enabling
37/// true zero-copy access when used with memory-mapped files.
38///
39/// # Example
40///
41/// ```ignore
42/// use apr_format::v2::AprV2ReaderRef;
43///
44/// let bytes: &[u8] = /* mmap'd slice */;
45/// let reader = AprV2ReaderRef::from_bytes(bytes)?;
46/// let weights = reader.get_f32_tensor("embed_tokens.weight")?;
47/// ```
48#[derive(Debug)]
49pub struct AprV2ReaderRef<'a> {
50    header: AprV2Header,
51    metadata: AprV2Metadata,
52    tensor_index: Vec<TensorIndexEntry>,
53    data: &'a [u8],
54}
55
56/// Parse and bounds-check the JSON metadata section (FALSIFY-PARSE-001).
57///
58/// All offsets/sizes here come straight from the (attacker-controllable) file
59/// header — the CRC32 checksum is computed over the header itself, so a
60/// corrupted file can carry a matching checksum. Therefore every offset+size is
61/// validated with checked arithmetic and `slice::get` (never `[start..end]`
62/// indexing, which panics on out-of-range / `start > end`).
63fn parse_metadata_section(
64    data: &[u8],
65    metadata_offset: u64,
66    metadata_size: u32,
67) -> Result<AprV2Metadata, V2FormatError> {
68    let start = usize::try_from(metadata_offset)
69        .map_err(|_| V2FormatError::InvalidHeader("metadata_offset exceeds usize".to_string()))?;
70    let end = start
71        .checked_add(metadata_size as usize)
72        .ok_or_else(|| V2FormatError::InvalidHeader("metadata offset+size overflow".to_string()))?;
73    let slice = data
74        .get(start..end)
75        .ok_or_else(|| V2FormatError::InvalidHeader("file too small for metadata".to_string()))?;
76    AprV2Metadata::from_json(slice)
77}
78
79/// Parse and bounds-check the tensor index section (FALSIFY-PARSE-001).
80///
81/// `tensor_index_offset` is attacker-controllable; previously it was used
82/// directly as `&data[pos..]`, which PANICS ("range start index out of bounds")
83/// when the offset points past EOF. Now the start offset is validated against
84/// the file length before slicing, and each entry advances `pos` with the same
85/// `slice::get` guard.
86fn parse_tensor_index_section(
87    data: &[u8],
88    tensor_index_offset: u64,
89    tensor_count: u32,
90) -> Result<Vec<TensorIndexEntry>, V2FormatError> {
91    let mut pos = usize::try_from(tensor_index_offset).map_err(|_| {
92        V2FormatError::InvalidTensorIndex("tensor_index_offset exceeds usize".to_string())
93    })?;
94
95    let mut tensor_index = Vec::with_capacity(tensor_count as usize);
96    for _ in 0..tensor_count {
97        // `data.get(pos..)` returns None only when pos > data.len(); pos == len
98        // yields an empty slice, which TensorIndexEntry::from_bytes rejects
99        // cleanly. This replaces the panicking `&data[pos..]`.
100        let remaining = data.get(pos..).ok_or_else(|| {
101            V2FormatError::InvalidTensorIndex("tensor index offset past end of file".to_string())
102        })?;
103        let (entry, consumed) = TensorIndexEntry::from_bytes(remaining)?;
104        tensor_index.push(entry);
105        pos = pos.checked_add(consumed).ok_or_else(|| {
106            V2FormatError::InvalidTensorIndex("tensor index position overflow".to_string())
107        })?;
108    }
109
110    // Verify tensor names are sorted
111    for i in 1..tensor_index.len() {
112        if tensor_index[i].name < tensor_index[i - 1].name {
113            return Err(V2FormatError::InvalidTensorIndex(
114                "tensor index not sorted".to_string(),
115            ));
116        }
117    }
118
119    Ok(tensor_index)
120}
121
122/// The minimum file length the container's own header + tensor index imply
123/// (issue #2612).
124///
125/// # The invariant
126///
127/// ```text
128/// data_offset + max(entry.offset + align_64(entry.size)) <= file_length
129/// ```
130///
131/// Every byte of every tensor the index declares must exist inside the file,
132/// **and so must the 64-byte alignment padding that follows it** — both APR v2
133/// writers (`AprV2Writer::write`, `AprV2StreamingWriter::add_tensor`) pad every
134/// tensor unconditionally, the last one included. This is pure arithmetic over
135/// the container's self-description: it reads no tensor data, so it costs
136/// O(tensor_count) regardless of file size, and it holds for every APR v2
137/// writer, because the on-disk order is header, metadata, index, data, footer —
138/// the declared extent of a complete file is always bounded by EOF.
139///
140/// The GGUF path has enforced the same invariant since GH-707 / S1-FIX
141/// (`Truncated GGUF: file is N bytes but tensor data starts at byte M`). The
142/// APR path had no equivalent, and the asymmetry is exactly why a `.apr`
143/// truncated to 4.5% of its length validated clean: header, metadata and index
144/// all live in FRONT of the data section, so every structure the reader
145/// actually parses survives the truncation intact.
146///
147/// # The residual, stated precisely
148///
149/// Both writers append a **4-byte CRC32 footer** after the padded data section,
150/// so the true length of a file they produced is `required_file_len(data) + 4`.
151/// This function deliberately stops short of it, and the reason is a
152/// measurement, not caution: of the ten parseable APR v2 files in the local
153/// corpus, **two carry no footer at all** —
154/// `~/models/qwen2.5-coder-1.5b-instruct-q4k.apr` and its `-q4k-v2` sibling are
155/// each exactly `required_file_len` bytes long, four short of
156/// `required_file_len + 4`. Requiring the footer would report both intact files
157/// as truncated. So a file missing only its last 4 bytes still passes this
158/// check; catching that needs check 4 (footer CRC32), which is still a declared
159/// `Skip("Footer not implemented")` stub — and which cannot simply be switched
160/// on for the same reason those two files just demonstrated.
161///
162/// Including the padding, verified against the same corpus, removes the rest of
163/// the tail slack: the unpadded bound left up to 63 further bytes undetected
164/// (measured 60 on `whisper.apr/models/tiny-int8.apr`, 36 on the in-tree
165/// `tests/fixtures/golden_v2.apr`), and the padded bound is `<= file_length` on
166/// all ten.
167///
168/// # Errors
169///
170/// Returns [`V2FormatError`] when `data` is not an APR v2 container at all (too
171/// short for the header, or wrong magic) — the caller cannot conclude anything
172/// about truncation in that case — or when the tensor index itself cannot be
173/// parsed, which IS evidence of a damaged file and should be reported as such.
174pub fn required_file_len(data: &[u8]) -> Result<u64, V2FormatError> {
175    // 64-byte alignment, in u64 so a u64 tensor size never round-trips through
176    // usize (32-bit targets) on its way to the comparison.
177    const ALIGN: u64 = 64;
178
179    let header = AprV2Header::from_bytes(data)?;
180    let tensor_index =
181        parse_tensor_index_section(data, header.tensor_index_offset, header.tensor_count)?;
182
183    let mut required = header.data_offset;
184    for entry in &tensor_index {
185        let overflow = || {
186            V2FormatError::InvalidTensorIndex(format!(
187                "tensor '{}' extent overflows u64 (offset {}, size {})",
188                entry.name, entry.offset, entry.size
189            ))
190        };
191        let padded_size = entry
192            .size
193            .checked_add(ALIGN - 1)
194            .map(|v| v & !(ALIGN - 1))
195            .ok_or_else(overflow)?;
196        let end = header
197            .data_offset
198            .checked_add(entry.offset)
199            .and_then(|start| start.checked_add(padded_size))
200            .ok_or_else(overflow)?;
201        required = required.max(end);
202    }
203    Ok(required)
204}
205
206impl AprV2Reader {
207    /// Read from bytes
208    ///
209    /// # Errors
210    /// Returns error if parsing fails.
211    ///
212    /// # LAYOUT-002 Jidoka Guard
213    /// Rejects APR files with `LAYOUT_COLUMN_MAJOR` flag set, as these indicate
214    /// improperly converted GGUF files that would produce garbage output.
215    pub fn from_bytes(data: &[u8]) -> Result<Self, V2FormatError> {
216        if data.len() < HEADER_SIZE_V2 {
217            return Err(V2FormatError::InvalidHeader("file too small".to_string()));
218        }
219
220        // Parse header
221        let header = AprV2Header::from_bytes(data)?;
222
223        // Verify checksum
224        if !header.verify_checksum() {
225            return Err(V2FormatError::ChecksumMismatch);
226        }
227
228        // LAYOUT-002: Jidoka Guard - Reject "dirty" APR files with column-major layout
229        if !header.flags.is_layout_valid() {
230            return Err(V2FormatError::InvalidHeader(
231                "LAYOUT-002 violation: APR file has LAYOUT_COLUMN_MAJOR flag set. \
232                 This indicates a dirty import from GGUF without proper transpose. \
233                 Re-import the model using `apr import` with LAYOUT-002 enforcement."
234                    .to_string(),
235            ));
236        }
237
238        // Parse metadata (FALSIFY-PARSE-001 / PMAT-822: checked arithmetic +
239        // .get() so a corrupted metadata_offset/size can never panic-slice).
240        let metadata = parse_metadata_section(data, header.metadata_offset, header.metadata_size)?;
241
242        // Parse tensor index
243        let tensor_index =
244            parse_tensor_index_section(data, header.tensor_index_offset, header.tensor_count)?;
245
246        Ok(Self {
247            header,
248            metadata,
249            tensor_index,
250            data: data.to_vec(),
251        })
252    }
253
254    /// Read from a Read impl
255    ///
256    /// # Errors
257    /// Returns error if read fails.
258    pub fn from_reader<R: Read>(reader: &mut R) -> Result<Self, V2FormatError> {
259        let mut data = Vec::new();
260        reader
261            .read_to_end(&mut data)
262            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
263        Self::from_bytes(&data)
264    }
265
266    /// Get header
267    #[must_use]
268    pub fn header(&self) -> &AprV2Header {
269        &self.header
270    }
271
272    /// Get metadata
273    #[must_use]
274    pub fn metadata(&self) -> &AprV2Metadata {
275        &self.metadata
276    }
277
278    /// Get tensor names
279    #[must_use]
280    pub fn tensor_names(&self) -> Vec<&str> {
281        self.tensor_index.iter().map(|e| e.name.as_str()).collect()
282    }
283
284    /// Get tensor by name
285    #[must_use]
286    pub fn get_tensor(&self, name: &str) -> Option<&TensorIndexEntry> {
287        self.tensor_index.iter().find(|e| e.name == name)
288    }
289
290    /// Get tensor data by name
291    #[must_use]
292    pub fn get_tensor_data(&self, name: &str) -> Option<&[u8]> {
293        let entry = self.get_tensor(name)?;
294        // FALSIFY-PARSE-001 / PMAT-822: data_offset + offset (u64) and
295        // start + size (usize) can both wrap for a crafted header, letting a
296        // wrapped `end <= len` check pass over an OOB region. Use checked
297        // arithmetic + `slice::get` so any overflow / past-EOF range → None.
298        let abs_offset = self.header.data_offset.checked_add(entry.offset)?;
299        let start = usize::try_from(abs_offset).ok()?;
300        let end = start.checked_add(usize::try_from(entry.size).ok()?)?;
301        self.data.get(start..end)
302    }
303
304    /// Get tensor as f32 slice (F32 dtype only)
305    #[must_use]
306    pub fn get_f32_tensor(&self, name: &str) -> Option<Vec<f32>> {
307        let entry = self.get_tensor(name)?;
308        if entry.dtype != TensorDType::F32 {
309            return None;
310        }
311
312        let data = self.get_tensor_data(name)?;
313        // `as_chunks::<4>()` rather than `chunks_exact(4)`: same semantics
314        // (the ragged tail is the discarded `.1`), but it yields `&[u8; 4]`,
315        // so `from_le_bytes` takes the array directly instead of a
316        // reassembled one. clippy::chunks_exact_to_as_chunks (new in 1.98)
317        // flags the old form; caught by the Toolchain Ceiling lane, which is
318        // exactly the drift it exists to catch.
319        let floats: Vec<f32> = data
320            .as_chunks::<4>()
321            .0
322            .iter()
323            .map(|chunk| f32::from_le_bytes(*chunk))
324            .collect();
325
326        Some(floats)
327    }
328
329    // NOTE (issue #2231): `get_tensor_as_f32` (the dequantizing accessor) is
330    // SEVERED from the sovereign leaf — it needed the GGUF Q4_K/Q6_K dequant
331    // kernels + the f16-scaled `dequantize_q4`, all framework/quant concerns.
332    // `aprender-core` re-attaches it via the `AprV2DequantExt` extension trait.
333
334    /// Check if all tensors are 64-byte aligned
335    #[must_use]
336    pub fn verify_alignment(&self) -> bool {
337        let data_offset = self.header.data_offset as usize;
338        self.tensor_index
339            .iter()
340            .all(|e| is_aligned_64(data_offset + e.offset as usize))
341    }
342
343    /// Borrow the parsed tensor index (used by the core dequant extension).
344    #[must_use]
345    pub fn tensor_index(&self) -> &[TensorIndexEntry] {
346        &self.tensor_index
347    }
348}
349
350impl<'a> AprV2ReaderRef<'a> {
351    /// Read from bytes (zero-copy - borrows data)
352    ///
353    /// Unlike `AprV2Reader::from_bytes`, this does NOT copy the input data.
354    /// The reader borrows the slice, making it ideal for use with mmap.
355    ///
356    /// # Errors
357    /// Returns error if parsing fails.
358    ///
359    /// # LAYOUT-002 Jidoka Guard
360    /// Rejects APR files with `LAYOUT_COLUMN_MAJOR` flag set, as these indicate
361    /// improperly converted GGUF files that would produce garbage output.
362    pub fn from_bytes(data: &'a [u8]) -> Result<Self, V2FormatError> {
363        if data.len() < HEADER_SIZE_V2 {
364            return Err(V2FormatError::InvalidHeader("file too small".to_string()));
365        }
366
367        // Parse header
368        let header = AprV2Header::from_bytes(data)?;
369
370        // Verify checksum
371        if !header.verify_checksum() {
372            return Err(V2FormatError::ChecksumMismatch);
373        }
374
375        // LAYOUT-002: Jidoka Guard - Reject "dirty" APR files with column-major layout
376        if !header.flags.is_layout_valid() {
377            return Err(V2FormatError::InvalidHeader(
378                "LAYOUT-002 violation: APR file has LAYOUT_COLUMN_MAJOR flag set. \
379                 This indicates a dirty import from GGUF without proper transpose. \
380                 Re-import the model using `apr import` with LAYOUT-002 enforcement."
381                    .to_string(),
382            ));
383        }
384
385        // Parse metadata (FALSIFY-PARSE-001 / PMAT-822: checked arithmetic +
386        // .get() so a corrupted metadata_offset/size can never panic-slice).
387        let metadata = parse_metadata_section(data, header.metadata_offset, header.metadata_size)?;
388
389        // Parse tensor index
390        let tensor_index =
391            parse_tensor_index_section(data, header.tensor_index_offset, header.tensor_count)?;
392
393        Ok(Self {
394            header,
395            metadata,
396            tensor_index,
397            data, // Borrow, no copy!
398        })
399    }
400
401    /// Get header
402    #[must_use]
403    pub fn header(&self) -> &AprV2Header {
404        &self.header
405    }
406
407    /// Get metadata
408    #[must_use]
409    pub fn metadata(&self) -> &AprV2Metadata {
410        &self.metadata
411    }
412
413    /// Get tensor names
414    #[must_use]
415    pub fn tensor_names(&self) -> Vec<&str> {
416        self.tensor_index.iter().map(|e| e.name.as_str()).collect()
417    }
418
419    /// Get tensor by name
420    #[must_use]
421    pub fn get_tensor(&self, name: &str) -> Option<&TensorIndexEntry> {
422        self.tensor_index.iter().find(|e| e.name == name)
423    }
424
425    /// Get tensor data by name (zero-copy slice into mmap)
426    #[must_use]
427    pub fn get_tensor_data(&self, name: &str) -> Option<&[u8]> {
428        let entry = self.get_tensor(name)?;
429        // FALSIFY-PARSE-001 / PMAT-822: data_offset + offset (u64) and
430        // start + size (usize) can both wrap for a crafted header, letting a
431        // wrapped `end <= len` check pass over an OOB region. Use checked
432        // arithmetic + `slice::get` so any overflow / past-EOF range → None.
433        let abs_offset = self.header.data_offset.checked_add(entry.offset)?;
434        let start = usize::try_from(abs_offset).ok()?;
435        let end = start.checked_add(usize::try_from(entry.size).ok()?)?;
436        self.data.get(start..end)
437    }
438
439    /// Get tensor as f32 Vec (copies data from mmap to `Vec<f32>`)
440    ///
441    /// Note: This allocates memory for the f32 values. For very large tensors,
442    /// consider using `get_tensor_data` and processing in chunks.
443    #[must_use]
444    pub fn get_f32_tensor(&self, name: &str) -> Option<Vec<f32>> {
445        let entry = self.get_tensor(name)?;
446        if entry.dtype != TensorDType::F32 {
447            return None;
448        }
449
450        let data = self.get_tensor_data(name)?;
451        // `as_chunks::<4>()` rather than `chunks_exact(4)`: same semantics
452        // (the ragged tail is the discarded `.1`), but it yields `&[u8; 4]`,
453        // so `from_le_bytes` takes the array directly instead of a
454        // reassembled one. clippy::chunks_exact_to_as_chunks (new in 1.98)
455        // flags the old form; caught by the Toolchain Ceiling lane, which is
456        // exactly the drift it exists to catch.
457        let floats: Vec<f32> = data
458            .as_chunks::<4>()
459            .0
460            .iter()
461            .map(|chunk| f32::from_le_bytes(*chunk))
462            .collect();
463
464        Some(floats)
465    }
466
467    // NOTE (issue #2231): `get_tensor_as_f32` severed from the leaf — see the
468    // owning-reader note above. Re-attached in core via `AprV2DequantExt`.
469
470    /// Check if all tensors are 64-byte aligned
471    #[must_use]
472    pub fn verify_alignment(&self) -> bool {
473        let data_offset = self.header.data_offset as usize;
474        self.tensor_index
475            .iter()
476            .all(|e| is_aligned_64(data_offset + e.offset as usize))
477    }
478
479    /// Borrow the parsed tensor index (used by the core dequant extension).
480    #[must_use]
481    pub fn tensor_index(&self) -> &[TensorIndexEntry] {
482        &self.tensor_index
483    }
484}
485
486// ============================================================================
487// Shard Manifest
488// ============================================================================
489
490/// Shard manifest for multi-file models
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct ShardManifest {
493    /// Format version
494    pub version: String,
495    /// Total number of shards
496    pub shard_count: usize,
497    /// Total size in bytes
498    pub total_size: u64,
499    /// Total tensor count
500    pub tensor_count: usize,
501    /// Shard files
502    pub shards: Vec<ShardInfo>,
503    /// Tensor to shard mapping
504    pub weight_map: HashMap<String, usize>,
505}
506
507/// Information about a single shard
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct ShardInfo {
510    /// Shard filename
511    pub filename: String,
512    /// Shard index
513    pub index: usize,
514    /// Size in bytes
515    pub size: u64,
516    /// Tensor names in this shard
517    pub tensors: Vec<String>,
518}