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        let floats: Vec<f32> = data
314            .chunks_exact(4)
315            .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
316            .collect();
317
318        Some(floats)
319    }
320
321    // NOTE (issue #2231): `get_tensor_as_f32` (the dequantizing accessor) is
322    // SEVERED from the sovereign leaf — it needed the GGUF Q4_K/Q6_K dequant
323    // kernels + the f16-scaled `dequantize_q4`, all framework/quant concerns.
324    // `aprender-core` re-attaches it via the `AprV2DequantExt` extension trait.
325
326    /// Check if all tensors are 64-byte aligned
327    #[must_use]
328    pub fn verify_alignment(&self) -> bool {
329        let data_offset = self.header.data_offset as usize;
330        self.tensor_index
331            .iter()
332            .all(|e| is_aligned_64(data_offset + e.offset as usize))
333    }
334
335    /// Borrow the parsed tensor index (used by the core dequant extension).
336    #[must_use]
337    pub fn tensor_index(&self) -> &[TensorIndexEntry] {
338        &self.tensor_index
339    }
340}
341
342impl<'a> AprV2ReaderRef<'a> {
343    /// Read from bytes (zero-copy - borrows data)
344    ///
345    /// Unlike `AprV2Reader::from_bytes`, this does NOT copy the input data.
346    /// The reader borrows the slice, making it ideal for use with mmap.
347    ///
348    /// # Errors
349    /// Returns error if parsing fails.
350    ///
351    /// # LAYOUT-002 Jidoka Guard
352    /// Rejects APR files with `LAYOUT_COLUMN_MAJOR` flag set, as these indicate
353    /// improperly converted GGUF files that would produce garbage output.
354    pub fn from_bytes(data: &'a [u8]) -> Result<Self, V2FormatError> {
355        if data.len() < HEADER_SIZE_V2 {
356            return Err(V2FormatError::InvalidHeader("file too small".to_string()));
357        }
358
359        // Parse header
360        let header = AprV2Header::from_bytes(data)?;
361
362        // Verify checksum
363        if !header.verify_checksum() {
364            return Err(V2FormatError::ChecksumMismatch);
365        }
366
367        // LAYOUT-002: Jidoka Guard - Reject "dirty" APR files with column-major layout
368        if !header.flags.is_layout_valid() {
369            return Err(V2FormatError::InvalidHeader(
370                "LAYOUT-002 violation: APR file has LAYOUT_COLUMN_MAJOR flag set. \
371                 This indicates a dirty import from GGUF without proper transpose. \
372                 Re-import the model using `apr import` with LAYOUT-002 enforcement."
373                    .to_string(),
374            ));
375        }
376
377        // Parse metadata (FALSIFY-PARSE-001 / PMAT-822: checked arithmetic +
378        // .get() so a corrupted metadata_offset/size can never panic-slice).
379        let metadata = parse_metadata_section(data, header.metadata_offset, header.metadata_size)?;
380
381        // Parse tensor index
382        let tensor_index =
383            parse_tensor_index_section(data, header.tensor_index_offset, header.tensor_count)?;
384
385        Ok(Self {
386            header,
387            metadata,
388            tensor_index,
389            data, // Borrow, no copy!
390        })
391    }
392
393    /// Get header
394    #[must_use]
395    pub fn header(&self) -> &AprV2Header {
396        &self.header
397    }
398
399    /// Get metadata
400    #[must_use]
401    pub fn metadata(&self) -> &AprV2Metadata {
402        &self.metadata
403    }
404
405    /// Get tensor names
406    #[must_use]
407    pub fn tensor_names(&self) -> Vec<&str> {
408        self.tensor_index.iter().map(|e| e.name.as_str()).collect()
409    }
410
411    /// Get tensor by name
412    #[must_use]
413    pub fn get_tensor(&self, name: &str) -> Option<&TensorIndexEntry> {
414        self.tensor_index.iter().find(|e| e.name == name)
415    }
416
417    /// Get tensor data by name (zero-copy slice into mmap)
418    #[must_use]
419    pub fn get_tensor_data(&self, name: &str) -> Option<&[u8]> {
420        let entry = self.get_tensor(name)?;
421        // FALSIFY-PARSE-001 / PMAT-822: data_offset + offset (u64) and
422        // start + size (usize) can both wrap for a crafted header, letting a
423        // wrapped `end <= len` check pass over an OOB region. Use checked
424        // arithmetic + `slice::get` so any overflow / past-EOF range → None.
425        let abs_offset = self.header.data_offset.checked_add(entry.offset)?;
426        let start = usize::try_from(abs_offset).ok()?;
427        let end = start.checked_add(usize::try_from(entry.size).ok()?)?;
428        self.data.get(start..end)
429    }
430
431    /// Get tensor as f32 Vec (copies data from mmap to `Vec<f32>`)
432    ///
433    /// Note: This allocates memory for the f32 values. For very large tensors,
434    /// consider using `get_tensor_data` and processing in chunks.
435    #[must_use]
436    pub fn get_f32_tensor(&self, name: &str) -> Option<Vec<f32>> {
437        let entry = self.get_tensor(name)?;
438        if entry.dtype != TensorDType::F32 {
439            return None;
440        }
441
442        let data = self.get_tensor_data(name)?;
443        let floats: Vec<f32> = data
444            .chunks_exact(4)
445            .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
446            .collect();
447
448        Some(floats)
449    }
450
451    // NOTE (issue #2231): `get_tensor_as_f32` severed from the leaf — see the
452    // owning-reader note above. Re-attached in core via `AprV2DequantExt`.
453
454    /// Check if all tensors are 64-byte aligned
455    #[must_use]
456    pub fn verify_alignment(&self) -> bool {
457        let data_offset = self.header.data_offset as usize;
458        self.tensor_index
459            .iter()
460            .all(|e| is_aligned_64(data_offset + e.offset as usize))
461    }
462
463    /// Borrow the parsed tensor index (used by the core dequant extension).
464    #[must_use]
465    pub fn tensor_index(&self) -> &[TensorIndexEntry] {
466        &self.tensor_index
467    }
468}
469
470// ============================================================================
471// Shard Manifest
472// ============================================================================
473
474/// Shard manifest for multi-file models
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ShardManifest {
477    /// Format version
478    pub version: String,
479    /// Total number of shards
480    pub shard_count: usize,
481    /// Total size in bytes
482    pub total_size: u64,
483    /// Total tensor count
484    pub tensor_count: usize,
485    /// Shard files
486    pub shards: Vec<ShardInfo>,
487    /// Tensor to shard mapping
488    pub weight_map: HashMap<String, usize>,
489}
490
491/// Information about a single shard
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct ShardInfo {
494    /// Shard filename
495    pub filename: String,
496    /// Shard index
497    pub index: usize,
498    /// Size in bytes
499    pub size: u64,
500    /// Tensor names in this shard
501    pub tensors: Vec<String>,
502}