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}