Skip to main content

limnifs_core/
slab_reader.rs

1//! Slab reader — locates and extracts a drop's plaintext from a slab.
2//!
3//! The slab layout (spec §3.1) is:
4//!
5//! ```text
6//! +---------------------------------+
7//! | SlabHeader (fixed, 56 bytes)    |
8//! +---------------------------------+
9//! | DropRecord[0..n]                |   ← 48 bytes each
10//! +---------------------------------+
11//! | SolidWindow[0..m]               |   ← concatenated drop plaintexts
12//! +---------------------------------+
13//! | ECShards (optional)             |
14//! +---------------------------------+
15//! ```
16//!
17//! The slab header does not carry an explicit `drop_count`; readers
18//! derive it by walking records until the cursor would enter the
19//! solid window. The stop condition for a store-codec slab (the only
20//! kind the v0.1 writer emits) is:
21//!
22//! ```text
23//! cursor_position + Σ plaintext_len_so_far == total_length
24//! ```
25//!
26//! At that point the remaining bytes are the solid window, and each
27//! record's `(offset_in_window, len_in_window)` is an absolute byte
28//! range inside it.
29
30use crate::cursor::ManifestCursor;
31use crate::drop_record::{parse_drop_record, DropRecord, DROP_RECORD_LEN};
32use crate::error::CoreError;
33use crate::slab::{parse_slab_header, SlabHeader};
34
35/// Parsed slab: header + drop records + view onto the solid window.
36///
37/// `bytes` borrows the underlying slab buffer for lifetime `'a`. The
38/// `plaintext_for` accessor returns slices that borrow from `bytes`,
39/// so callers can keep those slices around as long as the slab buffer
40/// itself stays alive.
41#[derive(Debug, Clone)]
42pub struct SlabView<'a> {
43    bytes: &'a [u8],
44    header: SlabHeader,
45    drop_records: Vec<DropRecord>,
46    solid_window_start: usize,
47}
48
49impl SlabView<'_> {
50    /// The slab header.
51    #[must_use]
52    pub const fn header(&self) -> SlabHeader {
53        self.header
54    }
55
56    /// All drop records in this slab, in declaration order.
57    #[must_use]
58    pub fn drop_records(&self) -> &[DropRecord] {
59        &self.drop_records
60    }
61
62    /// Byte offset where the solid window begins (i.e. immediately
63    /// after the last drop record). Useful for diagnostics.
64    #[must_use]
65    pub const fn solid_window_offset(&self) -> usize {
66        self.solid_window_start
67    }
68
69    /// Find a drop record by its `DropId`. Linear scan.
70    #[must_use]
71    pub fn find_record(&self, drop_id: &[u8; 32]) -> Option<&DropRecord> {
72        self.drop_records
73            .iter()
74            .find(|r| r.drop_id.as_bytes() == drop_id)
75    }
76
77    /// Return the plaintext bytes for `drop_id`, or `None` if no drop
78    /// in this slab carries that id.
79    ///
80    /// Supports both store (0x00) and LZ4 (0x01) codecs. LZ4 drops
81    /// are decompressed on read. Non-plaintext AEADs and non-zero
82    /// `solid_window_index` are still rejected (v0.1 limitations).
83    ///
84    /// Returns owned bytes (not a borrowed slice) because LZ4
85    /// decompression produces new data that does not live in the slab
86    /// buffer.
87    ///
88    /// # Errors
89    ///
90    /// - [`CoreError::UnsupportedFeature`] if the drop uses an unknown
91    ///   codec, a non-plaintext AEAD, or a non-zero `solid_window_index`.
92    /// - [`CoreError::Corrupt`] if the slice would extend past the slab
93    ///   or decompression fails.
94    #[must_use]
95    pub fn plaintext_for(&self, drop_id: &[u8; 32]) -> Option<Result<Vec<u8>, CoreError>> {
96        self.plaintext_for_with_dict_lookup(drop_id, &|_| None)
97    }
98
99    /// Same as [`plaintext_for`](Self::plaintext_for) but with a
100    /// callback to resolve `dict_id` → dictionary bytes. Used by
101    /// `SlabStore` when the manifest's `dictionary_section` is
102    /// populated. For drops with `dict_id == NO_DICT` (0xFF), the
103    /// callback is not consulted.
104    ///
105    /// The callback returns the raw dictionary bytes for the given
106    /// dict_id, or `None` if the dict is unknown (which makes the
107    /// drop undecodable).
108    #[must_use]
109    pub fn plaintext_for_with_dict_lookup(
110        &self,
111        drop_id: &[u8; 32],
112        dict_lookup: &dyn Fn(u8) -> Option<Vec<u8>>,
113    ) -> Option<Result<Vec<u8>, CoreError>> {
114        let record = self.find_record(drop_id)?;
115        if record.representation.aead != 0x00 {
116            return Some(Err(CoreError::UnsupportedFeature {
117                feature: format!(
118                    "drop aead 0x{:02X} (only plaintext/0x00 supported in v0.1)",
119                    record.representation.aead
120                ),
121            }));
122        }
123        if record.solid_window_index != 0 {
124            return Some(Err(CoreError::UnsupportedFeature {
125                feature: format!(
126                    "solid_window_index {} (only single-window slabs supported in v0.1)",
127                    record.solid_window_index
128                ),
129            }));
130        }
131        let offset = usize::try_from(record.offset_in_window).ok()?;
132        let len = usize::try_from(record.len_in_window).ok()?;
133        let start = self.solid_window_start.checked_add(offset)?;
134        let end = start.checked_add(len)?;
135        if end > self.bytes.len() {
136            return Some(Err(CoreError::Corrupt {
137                reason: format!(
138                    "drop range [{start}..{end}] extends past slab length {}",
139                    self.bytes.len()
140                ),
141            }));
142        }
143        let raw = &self.bytes[start..end];
144        if record.flags & crate::seekable::DROP_FLAG_SEEKABLE != 0 {
145            if record.dict_id != crate::drop_record::NO_DICT {
146                return Some(Err(CoreError::UnsupportedFeature {
147                    feature: "seekable drop with trained dictionary (not combinable)".into(),
148                }));
149            }
150            return Some(crate::seekable::decode_seekable(
151                record.representation.codec,
152                raw,
153                record.plaintext_len,
154            ));
155        }
156        if record.dict_id == crate::drop_record::NO_DICT {
157            Some(crate::codec::decompress(
158                record.representation.codec,
159                raw,
160                record.plaintext_len,
161            ))
162        } else {
163            // Dictionary-compressed drop. Resolve the dict and use
164            // the dict-aware ZSTD decompress path.
165            let Some(dict_bytes) = dict_lookup(record.dict_id) else {
166                return Some(Err(CoreError::Corrupt {
167                    reason: format!(
168                        "drop references dict_id 0x{:02X} but no dictionary_section provided",
169                        record.dict_id
170                    ),
171                }));
172            };
173            Some(crate::codec::zstd_dict::decompress_with_dict(
174                raw,
175                record.plaintext_len,
176                &dict_bytes,
177            ))
178        }
179    }
180
181    /// Decompress only the plaintext bytes at `[off, off+len)` of
182    /// `drop_id`.
183    ///
184    /// Seekable (slab v2) drops decode only the covering container
185    /// frames — a cold 8 KiB window costs at most one 256 KiB frame.
186    /// Non-seekable drops decode the full payload and slice (the
187    /// caller-side cache makes repeat windows cheap; see
188    /// `crate::slab_cache`).
189    ///
190    /// Returns `None` if no drop in this slab carries that id.
191    /// `off + len` beyond the drop's plaintext is `Corrupt`.
192    #[must_use]
193    pub fn plaintext_range(
194        &self,
195        drop_id: &[u8; 32],
196        off: u64,
197        len: usize,
198    ) -> Option<Result<Vec<u8>, CoreError>> {
199        let record = self.find_record(drop_id)?;
200        if record.flags & crate::seekable::DROP_FLAG_SEEKABLE != 0 {
201            let (offset, end) = match self.drop_window_bounds(record) {
202                Ok(v) => v,
203                Err(e) => return Some(Err(e)),
204            };
205            let raw = &self.bytes[offset..end];
206            return Some(crate::seekable::decode_seekable_range(
207                record.representation.codec,
208                raw,
209                off,
210                len,
211            ));
212        }
213        // Non-seekable: full decode + slice.
214        let plaintext = self.plaintext_for(drop_id)?;
215        Some(match plaintext {
216            Ok(bytes) => {
217                let total = bytes.len() as u64;
218                if off > total || off + len as u64 > total {
219                    Err(CoreError::Corrupt {
220                        reason: format!(
221                            "drop range [{off}, {}) outside plaintext length {total}",
222                            off + len as u64
223                        ),
224                    })
225                } else {
226                    Ok(bytes[off as usize..off as usize + len].to_vec())
227                }
228            }
229            Err(e) => Err(e),
230        })
231    }
232
233    /// Resolve a record's byte range inside the slab buffer.
234    fn drop_window_bounds(&self, record: &DropRecord) -> Result<(usize, usize), CoreError> {
235        let offset = usize::try_from(record.offset_in_window).map_err(|_| CoreError::Corrupt {
236            reason: "drop offset_in_window exceeds usize".into(),
237        })?;
238        let len = usize::try_from(record.len_in_window).map_err(|_| CoreError::Corrupt {
239            reason: "drop len_in_window exceeds usize".into(),
240        })?;
241        let start =
242            self.solid_window_start
243                .checked_add(offset)
244                .ok_or_else(|| CoreError::Corrupt {
245                    reason: "drop window start overflows usize".into(),
246                })?;
247        let end = start.checked_add(len).ok_or_else(|| CoreError::Corrupt {
248            reason: "drop window end overflows usize".into(),
249        })?;
250        if end > self.bytes.len() {
251            return Err(CoreError::Corrupt {
252                reason: format!(
253                    "drop range [{start}..{end}] extends past slab length {}",
254                    self.bytes.len()
255                ),
256            });
257        }
258        Ok((start, end))
259    }
260}
261
262/// Parse a slab into a [`SlabView`] that exposes drop records and
263/// plaintext lookups.
264///
265/// Walks every drop record to derive the solid-window boundary. Only
266/// store-codec plaintext slabs (the kind the v0.1 writer emits) are
267/// supported; a slab whose records' `plaintext_len` values do not sum
268/// to the trailing byte count is rejected as `Corrupt`.
269///
270/// # Errors
271///
272/// - Inherits errors from [`parse_slab_header`] and [`parse_drop_record`].
273/// - [`CoreError::Corrupt`] if the drop-record / solid-window boundary
274///   cannot be derived consistently.
275pub fn parse_slab(bytes: &[u8]) -> Result<SlabView<'_>, CoreError> {
276    let mut cursor = ManifestCursor::new(bytes);
277    let header = parse_slab_header(&mut cursor)?;
278    let total_length = usize::try_from(header.total_length).map_err(|_| CoreError::Corrupt {
279        reason: format!("slab total_length {} exceeds usize", header.total_length),
280    })?;
281    if total_length != bytes.len() {
282        return Err(CoreError::Corrupt {
283            reason: format!(
284                "slab total_length {total_length} does not match buffer length {}",
285                bytes.len()
286            ),
287        });
288    }
289
290    let mut drop_records: Vec<DropRecord> = Vec::new();
291    let mut window_len_sum: u64 = 0;
292    loop {
293        let cursor_pos = u64::try_from(cursor.position()).map_err(|_| CoreError::Corrupt {
294            reason: format!("slab cursor position {} exceeds u64", cursor.position()),
295        })?;
296        let remaining_after_cursor =
297            header
298                .total_length
299                .checked_sub(cursor_pos)
300                .ok_or_else(|| CoreError::Corrupt {
301                    reason: format!(
302                        "slab cursor position {cursor_pos} past total_length {}",
303                        header.total_length
304                    ),
305                })?;
306        if remaining_after_cursor == window_len_sum {
307            break;
308        }
309        if remaining_after_cursor < window_len_sum {
310            return Err(CoreError::Corrupt {
311                reason: format!(
312                    "slab drop records overran solid window: cursor_pos={cursor_pos}, window_sum={window_len_sum}, total_length={}",
313                    header.total_length
314                ),
315            });
316        }
317        let trailing = remaining_after_cursor - window_len_sum;
318        if trailing < u64::try_from(DROP_RECORD_LEN).unwrap_or(u64::MAX) {
319            return Err(CoreError::Corrupt {
320                reason: format!(
321                    "slab has {trailing} trailing bytes that are neither a full drop record ({DROP_RECORD_LEN}B) nor accounted for by the solid window"
322                ),
323            });
324        }
325        let record = parse_drop_record(&mut cursor, &header)?;
326        window_len_sum = window_len_sum
327            .checked_add(u64::from(record.len_in_window))
328            .ok_or_else(|| CoreError::Corrupt {
329                reason: format!(
330                    "slab drop len_in_window sum overflow at record {}",
331                    drop_records.len()
332                ),
333            })?;
334        drop_records.push(record);
335    }
336
337    let solid_window_start = cursor.position();
338    Ok(SlabView {
339        bytes,
340        header,
341        drop_records,
342        solid_window_start,
343    })
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::slab::SLAB_HEADER_LEN;
350    use limnifs_format::DropId;
351
352    fn make_slab(drops: &[(&[u8; 32], &[u8])]) -> Vec<u8> {
353        let mut drop_records = Vec::new();
354        let mut solid_window = Vec::new();
355        for (id, plaintext) in drops {
356            let plaintext_len = u32::try_from(plaintext.len()).unwrap();
357            let offset_in_window = u32::try_from(solid_window.len()).unwrap();
358            drop_records.extend_from_slice(*id);
359            drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
360            drop_records.extend_from_slice(&[0x00, 0x00, 0x00]); // representation: store, plaintext, no EC
361            drop_records.push(0x00); // solid_window_index
362            drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
363            drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
364            drop_records.push(crate::drop_record::NO_DICT); // dict_id: no dictionary
365            drop_records.push(0x00); // flags
366            solid_window.extend_from_slice(plaintext);
367        }
368        let slab_content = [&drop_records[..], &solid_window[..]].concat();
369        let total_length = u64::try_from(SLAB_HEADER_LEN + slab_content.len()).unwrap();
370        let mut bytes = Vec::with_capacity(usize::try_from(total_length).expect("fits usize"));
371        bytes.extend_from_slice(b"LIM1");
372        bytes.extend_from_slice(&1u16.to_le_bytes());
373        bytes.extend_from_slice(&0u64.to_le_bytes()); // ordinal
374        bytes.extend_from_slice(&[0u8; 32]); // hash
375        bytes.extend_from_slice(&total_length.to_le_bytes());
376        bytes.push(0x00); // ec_descriptor
377        bytes.push(0x00); // crypto_hint
378        bytes.extend_from_slice(&slab_content);
379        bytes
380    }
381
382    #[test]
383    fn parses_empty_slab() {
384        let bytes = make_slab(&[]);
385        let view = parse_slab(&bytes).expect("empty slab parses");
386        assert_eq!(view.drop_records().len(), 0);
387    }
388
389    #[test]
390    fn parses_single_drop() {
391        let id = [0xAA; 32];
392        let plaintext = b"hello world";
393        let bytes = make_slab(&[(&id, plaintext)]);
394        let view = parse_slab(&bytes).expect("single-drop slab parses");
395        assert_eq!(view.drop_records().len(), 1);
396        let got = view
397            .plaintext_for(&id)
398            .expect("drop present")
399            .expect("store codec ok");
400        assert_eq!(got, plaintext);
401    }
402
403    #[test]
404    fn parses_multiple_drops() {
405        let id1 = [0x11; 32];
406        let id2 = [0x22; 32];
407        let id3 = [0x33; 32];
408        let p1 = b"first drop plaintext";
409        let p2 = b"second";
410        let p3 = b"third drop is longer than the others combined";
411        let bytes = make_slab(&[(&id1, p1), (&id2, p2), (&id3, p3)]);
412        let view = parse_slab(&bytes).expect("multi-drop slab parses");
413        assert_eq!(view.drop_records().len(), 3);
414        assert_eq!(
415            view.plaintext_for(&id1)
416                .expect("drop 1 present")
417                .expect("store codec ok"),
418            p1
419        );
420        assert_eq!(
421            view.plaintext_for(&id2)
422                .expect("drop 2 present")
423                .expect("store codec ok"),
424            p2
425        );
426        assert_eq!(
427            view.plaintext_for(&id3)
428                .expect("drop 3 present")
429                .expect("store codec ok"),
430            p3
431        );
432    }
433
434    #[test]
435    fn missing_drop_returns_none() {
436        let id = [0xAA; 32];
437        let bytes = make_slab(&[(&id, b"data")]);
438        let view = parse_slab(&bytes).expect("slab parses");
439        let missing = DropId::from_bytes([0xBB; 32]);
440        assert!(view.plaintext_for(missing.as_bytes()).is_none());
441    }
442
443    #[test]
444    fn rejects_buffer_length_mismatch() {
445        let id = [0xAA; 32];
446        let mut bytes = make_slab(&[(&id, b"data")]);
447        bytes.truncate(bytes.len() - 1);
448        match parse_slab(&bytes) {
449            Err(CoreError::Corrupt { reason }) => {
450                assert!(
451                    reason.contains("does not match buffer length"),
452                    "got: {reason}"
453                );
454            }
455            other => panic!("expected Corrupt, got {other:?}"),
456        }
457    }
458
459    #[test]
460    fn slab_from_writer_round_trips() {
461        // Build a slab using the writer's encoding helper and verify
462        // the reader can extract plaintexts.
463        let id1 = [0x11; 32];
464        let id2 = [0x22; 32];
465        let p1 = vec![0xAB; 4096];
466        let p2 = vec![0xCD; 1024];
467        let bytes = make_slab(&[(&id1, &p1), (&id2, &p2)]);
468        let view = parse_slab(&bytes).expect("writer-style slab parses");
469        assert_eq!(view.drop_records().len(), 2);
470        assert_eq!(
471            view.plaintext_for(&id1)
472                .expect("drop 1 present")
473                .expect("ok"),
474            &p1[..]
475        );
476        assert_eq!(
477            view.plaintext_for(&id2)
478                .expect("drop 2 present")
479                .expect("ok"),
480            &p2[..]
481        );
482    }
483}