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}