Skip to main content

revelo_core/
byte_source.rs

1use std::fmt;
2use std::io::{Read, Seek, SeekFrom};
3use std::ops::Range;
4use std::path::Path;
5use std::sync::Mutex;
6
7/// A checked byte range in a media source.
8///
9/// Offsets and lengths are modeled as `u64` at the source boundary so large
10/// files cannot silently wrap `usize` arithmetic. Conversion to `usize` is
11/// delayed until a concrete in-memory window is materialized.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct ByteRange {
14    pub offset: u64,
15    pub len: u64,
16}
17
18impl ByteRange {
19    pub fn new(offset: u64, len: u64) -> Result<Self, ReadAtError> {
20        offset
21            .checked_add(len)
22            .ok_or(ReadAtError::RangeOverflow { offset, len })
23            .map(|_| Self { offset, len })
24    }
25
26    pub fn from_usize(offset: usize, len: usize) -> Result<Self, ReadAtError> {
27        Self::new(offset as u64, len as u64)
28    }
29
30    pub fn is_empty(&self) -> bool {
31        self.len == 0
32    }
33
34    pub fn end_exclusive(&self) -> u64 {
35        self.offset + self.len
36    }
37
38    fn len_usize(&self) -> Result<usize, ReadAtError> {
39        usize::try_from(self.len).map_err(|_| ReadAtError::RangeTooLarge { len: self.len })
40    }
41}
42
43/// Typed failures for random-access reads.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ReadAtError {
46    RangeOverflow { offset: u64, len: u64 },
47    RangeTooLarge { len: u64 },
48    OffsetOutOfBounds { offset: u64, source_len: u64 },
49    RangeOutOfBounds { offset: u64, len: u64, source_len: u64 },
50    DestinationTooSmall { requested: usize, available: usize },
51    UnavailableWindow,
52    Io { message: String },
53}
54
55impl From<std::io::Error> for ReadAtError {
56    fn from(value: std::io::Error) -> Self {
57        ReadAtError::Io { message: value.to_string() }
58    }
59}
60
61impl fmt::Display for ReadAtError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            ReadAtError::RangeOverflow { offset, len } => {
65                write!(f, "byte range overflows: offset={offset}, len={len}")
66            }
67            ReadAtError::RangeTooLarge { len } => {
68                write!(f, "byte range length does not fit in memory: len={len}")
69            }
70            ReadAtError::OffsetOutOfBounds { offset, source_len } => {
71                write!(f, "offset {offset} is outside source length {source_len}")
72            }
73            ReadAtError::RangeOutOfBounds { offset, len, source_len } => {
74                write!(f, "range offset={offset}, len={len} exceeds source length {source_len}")
75            }
76            ReadAtError::DestinationTooSmall { requested, available } => {
77                write!(f, "destination too small: requested={requested}, available={available}")
78            }
79            ReadAtError::UnavailableWindow => write!(f, "requested byte window is unavailable"),
80            ReadAtError::Io { message } => write!(f, "source I/O error: {message}"),
81        }
82    }
83}
84
85impl std::error::Error for ReadAtError {}
86
87/// Optional source-level counters for random-access backends.
88#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
89pub struct SourceStats {
90    pub read_at_calls: u64,
91    pub window_at_calls: u64,
92    pub bytes_requested: u64,
93    pub bytes_returned: u64,
94    pub max_request_len: u64,
95}
96
97/// Canonical random-access/windowed source boundary.
98pub trait MediaReadAt: std::fmt::Debug {
99    /// Total length of the source in bytes.
100    fn len_u64(&self) -> u64;
101
102    fn is_empty(&self) -> bool {
103        self.len_u64() == 0
104    }
105
106    /// Copy exactly `range.len` bytes into `dst`.
107    fn read_at(&self, range: ByteRange, dst: &mut [u8]) -> Result<usize, ReadAtError>;
108
109    /// Borrow exactly `range.len` bytes from the source.
110    fn window_at(&self, range: ByteRange) -> Result<&[u8], ReadAtError>;
111
112    /// Borrow up to `range.len` bytes. The offset must still be in bounds.
113    fn window_at_partial(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
114        self.window_at(range)
115    }
116
117    /// Return the complete contiguous source only for compatibility paths.
118    fn as_contiguous(&self) -> Option<&[u8]> {
119        None
120    }
121
122    fn stats(&self) -> SourceStats {
123        SourceStats::default()
124    }
125}
126
127/// A legacy source of bytes addressable at arbitrary offsets.
128///
129/// # Implementors
130///
131/// | Type | I/O model | Use when |
132/// |---|---|---|
133/// | [`SliceBackend`] | Zero-copy `&[u8]` borrow | Bytes already in memory |
134/// | [`MmapBackend`] | Zero-copy mmap, OS pages on demand | Local files of any size |
135/// | (future) `ReadBackend::Streamed` | Sliding window over `Read + Seek` | HTTP range requests, pipes |
136///
137/// # Future: `Read + Seek` sources
138///
139/// A `Streamed` variant wrapping a `Box<dyn ReadAndSeek>` or similar
140/// is the natural extension. It would maintain an internal sliding window:
141/// reads that fall within the current window return `&[u8]` slices;
142/// reads beyond it trigger a window shift via `Seek::seek` + `Read::read_exact`.
143/// Callers that need this today can prototype by reading the file on
144/// their own terms and using [`ReadBackend::Slice`].
145pub trait ByteSource: std::fmt::Debug {
146    /// Total length of the source in bytes.
147    fn len(&self) -> usize;
148
149    /// Whether the source is empty.
150    fn is_empty(&self) -> bool {
151        self.len() == 0
152    }
153
154    /// Return the byte at absolute `offset`, or `None` if out of bounds.
155    fn byte_at(&self, offset: usize) -> Option<u8>;
156
157    /// Return a slice of `len` bytes starting at absolute `offset`,
158    /// or `None` if the range is not fully available.
159    fn slice_at(&self, offset: usize, len: usize) -> Option<&[u8]>;
160}
161
162/// Seekable file-backed media source.
163///
164/// This backend can copy byte ranges with [`MediaReadAt::read_at`], but it does
165/// not expose borrowed windows. A future sliding-window backend can build on
166/// the same typed range/error contract while adding cache-backed `window_at`.
167#[derive(Debug)]
168pub struct FileBackend {
169    file: Mutex<std::fs::File>,
170    len: u64,
171}
172
173impl FileBackend {
174    pub fn open(path: impl AsRef<Path>) -> Result<Self, ReadAtError> {
175        let file = std::fs::File::open(path).map_err(ReadAtError::from)?;
176        Self::from_file(file)
177    }
178
179    pub fn from_file(file: std::fs::File) -> Result<Self, ReadAtError> {
180        let len = file.metadata().map_err(ReadAtError::from)?.len();
181        Ok(Self { file: Mutex::new(file), len })
182    }
183}
184
185impl MediaReadAt for FileBackend {
186    fn len_u64(&self) -> u64 {
187        self.len
188    }
189
190    fn read_at(&self, range: ByteRange, dst: &mut [u8]) -> Result<usize, ReadAtError> {
191        let requested_len = range.len_usize()?;
192        if dst.len() < requested_len {
193            return Err(ReadAtError::DestinationTooSmall {
194                requested: requested_len,
195                available: dst.len(),
196            });
197        }
198        if range.end_exclusive() > self.len {
199            return Err(ReadAtError::RangeOutOfBounds {
200                offset: range.offset,
201                len: range.len,
202                source_len: self.len,
203            });
204        }
205        let mut file = self
206            .file
207            .lock()
208            .map_err(|_| ReadAtError::Io { message: "file backend mutex poisoned".to_string() })?;
209        file.seek(SeekFrom::Start(range.offset)).map_err(ReadAtError::from)?;
210        file.read_exact(&mut dst[..requested_len]).map_err(ReadAtError::from)?;
211        Ok(requested_len)
212    }
213
214    fn window_at(&self, _range: ByteRange) -> Result<&[u8], ReadAtError> {
215        Err(ReadAtError::UnavailableWindow)
216    }
217}
218
219/// Borrowed in-memory media source.
220#[derive(Debug, Clone, Copy)]
221pub struct SliceBackend<'a> {
222    bytes: &'a [u8],
223}
224
225impl<'a> SliceBackend<'a> {
226    pub fn new(bytes: &'a [u8]) -> Self {
227        Self { bytes }
228    }
229
230    pub fn as_slice(&self) -> &'a [u8] {
231        self.bytes
232    }
233}
234
235impl MediaReadAt for SliceBackend<'_> {
236    fn len_u64(&self) -> u64 {
237        self.bytes.len() as u64
238    }
239
240    fn read_at(&self, range: ByteRange, dst: &mut [u8]) -> Result<usize, ReadAtError> {
241        let source = ReadBackend::Slice(self.bytes);
242        source.read_at(range, dst)
243    }
244
245    fn window_at(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
246        let bounds = exact_bounds(range, self.bytes.len())?;
247        Ok(&self.bytes[bounds])
248    }
249
250    fn window_at_partial(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
251        let bounds = partial_bounds(range, self.bytes.len())?;
252        Ok(&self.bytes[bounds])
253    }
254
255    fn as_contiguous(&self) -> Option<&[u8]> {
256        Some(self.bytes)
257    }
258}
259
260/// Borrowed memory-mapped media source.
261#[cfg(feature = "mmap")]
262#[derive(Debug, Clone, Copy)]
263pub struct MmapBackend<'a> {
264    mmap: &'a memmap2::Mmap,
265}
266
267#[cfg(feature = "mmap")]
268impl<'a> MmapBackend<'a> {
269    pub fn new(mmap: &'a memmap2::Mmap) -> Self {
270        Self { mmap }
271    }
272
273    pub fn as_slice(&self) -> &'a [u8] {
274        self.mmap.as_ref()
275    }
276}
277
278#[cfg(feature = "mmap")]
279impl MediaReadAt for MmapBackend<'_> {
280    fn len_u64(&self) -> u64 {
281        self.mmap.len() as u64
282    }
283
284    fn read_at(&self, range: ByteRange, dst: &mut [u8]) -> Result<usize, ReadAtError> {
285        let source = ReadBackend::Mapped(self.mmap);
286        source.read_at(range, dst)
287    }
288
289    fn window_at(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
290        let bytes = self.mmap.as_ref();
291        let bounds = exact_bounds(range, bytes.len())?;
292        Ok(&bytes[bounds])
293    }
294
295    fn window_at_partial(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
296        let bytes = self.mmap.as_ref();
297        let bounds = partial_bounds(range, bytes.len())?;
298        Ok(&bytes[bounds])
299    }
300
301    fn as_contiguous(&self) -> Option<&[u8]> {
302        Some(self.mmap.as_ref())
303    }
304}
305
306/// The concrete byte source used by [`FileAnalyze`](crate::FileAnalyze).
307///
308/// An enum with two zero-copy variants today; a `Streamed` variant for
309/// `Read + Seek` sources is planned but not yet implemented.
310#[derive(Debug, Clone, Copy)]
311pub enum ReadBackend<'a> {
312    /// Bytes already in memory — the classic `&[u8]` path.
313    Slice(&'a [u8]),
314    /// Operating-system memory-mapped file. The backing file is opened
315    /// read-only and the OS faults in pages on demand.
316    ///
317    /// Only available when the `mmap` feature is enabled (on by default;
318    /// disabled for WASM builds).
319    #[cfg(feature = "mmap")]
320    Mapped(&'a memmap2::Mmap),
321}
322
323impl<'a> ReadBackend<'a> {
324    /// View the entire backend as a contiguous `&[u8]` slice.
325    ///
326    /// Both `Slice` and `Mapped` implement efficient deref to `[u8]`;
327    /// this method dispatches to the active variant.
328    #[inline]
329    pub fn as_slice(&self) -> &[u8] {
330        match self {
331            ReadBackend::Slice(s) => s,
332            #[cfg(feature = "mmap")]
333            ReadBackend::Mapped(m) => m.as_ref(),
334        }
335    }
336
337    #[inline]
338    pub fn len(&self) -> usize {
339        self.as_slice().len()
340    }
341
342    #[inline]
343    pub fn is_empty(&self) -> bool {
344        self.len() == 0
345    }
346}
347
348fn exact_bounds(range: ByteRange, source_len: usize) -> Result<Range<usize>, ReadAtError> {
349    bounds(range, source_len, false)
350}
351
352fn partial_bounds(range: ByteRange, source_len: usize) -> Result<Range<usize>, ReadAtError> {
353    bounds(range, source_len, true)
354}
355
356fn bounds(
357    range: ByteRange,
358    source_len: usize,
359    allow_partial: bool,
360) -> Result<Range<usize>, ReadAtError> {
361    let source_len_u64 = source_len as u64;
362    let start = usize::try_from(range.offset).map_err(|_| ReadAtError::OffsetOutOfBounds {
363        offset: range.offset,
364        source_len: source_len_u64,
365    })?;
366    if start > source_len || (start == source_len && !range.is_empty()) {
367        return Err(ReadAtError::OffsetOutOfBounds {
368            offset: range.offset,
369            source_len: source_len_u64,
370        });
371    }
372    let requested_len = range.len_usize()?;
373    let requested_end = start
374        .checked_add(requested_len)
375        .ok_or(ReadAtError::RangeOverflow { offset: range.offset, len: range.len })?;
376    if requested_end <= source_len {
377        Ok(start..requested_end)
378    } else if allow_partial {
379        Ok(start..source_len)
380    } else {
381        Err(ReadAtError::RangeOutOfBounds {
382            offset: range.offset,
383            len: range.len,
384            source_len: source_len_u64,
385        })
386    }
387}
388
389impl MediaReadAt for ReadBackend<'_> {
390    #[inline]
391    fn len_u64(&self) -> u64 {
392        self.as_slice().len() as u64
393    }
394
395    fn read_at(&self, range: ByteRange, dst: &mut [u8]) -> Result<usize, ReadAtError> {
396        let requested_len = range.len_usize()?;
397        if dst.len() < requested_len {
398            return Err(ReadAtError::DestinationTooSmall {
399                requested: requested_len,
400                available: dst.len(),
401            });
402        }
403        let window = self.window_at(range)?;
404        dst[..requested_len].copy_from_slice(window);
405        Ok(requested_len)
406    }
407
408    fn window_at(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
409        let bounds = exact_bounds(range, self.as_slice().len())?;
410        Ok(&self.as_slice()[bounds])
411    }
412
413    fn window_at_partial(&self, range: ByteRange) -> Result<&[u8], ReadAtError> {
414        let bounds = partial_bounds(range, self.as_slice().len())?;
415        Ok(&self.as_slice()[bounds])
416    }
417
418    fn as_contiguous(&self) -> Option<&[u8]> {
419        Some(self.as_slice())
420    }
421}
422
423impl ByteSource for ReadBackend<'_> {
424    #[inline]
425    fn len(&self) -> usize {
426        self.as_slice().len()
427    }
428
429    #[inline]
430    fn byte_at(&self, offset: usize) -> Option<u8> {
431        self.as_slice().get(offset).copied()
432    }
433
434    #[inline]
435    fn slice_at(&self, offset: usize, len: usize) -> Option<&[u8]> {
436        let range = ByteRange::from_usize(offset, len).ok()?;
437        self.window_at(range).ok()
438    }
439}
440
441impl<'a> From<&'a [u8]> for ReadBackend<'a> {
442    #[inline]
443    fn from(slice: &'a [u8]) -> Self {
444        ReadBackend::Slice(slice)
445    }
446}
447
448impl<'a> From<SliceBackend<'a>> for ReadBackend<'a> {
449    #[inline]
450    fn from(slice: SliceBackend<'a>) -> Self {
451        ReadBackend::Slice(slice.as_slice())
452    }
453}
454
455impl<'a> From<&'a Vec<u8>> for ReadBackend<'a> {
456    #[inline]
457    fn from(v: &'a Vec<u8>) -> Self {
458        ReadBackend::Slice(v.as_slice())
459    }
460}
461
462#[cfg(feature = "mmap")]
463impl<'a> From<&'a memmap2::Mmap> for ReadBackend<'a> {
464    #[inline]
465    fn from(mmap: &'a memmap2::Mmap) -> Self {
466        ReadBackend::Mapped(mmap)
467    }
468}
469
470#[cfg(feature = "mmap")]
471impl<'a> From<MmapBackend<'a>> for ReadBackend<'a> {
472    #[inline]
473    fn from(mmap: MmapBackend<'a>) -> Self {
474        ReadBackend::Mapped(mmap.mmap)
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn byte_range_rejects_offset_overflow() {
484        assert_eq!(
485            ByteRange::new(u64::MAX, 1),
486            Err(ReadAtError::RangeOverflow { offset: u64::MAX, len: 1 })
487        );
488    }
489
490    #[test]
491    fn read_backend_windows_exact_ranges() {
492        let bytes = [0, 1, 2, 3, 4, 5];
493        let source = ReadBackend::Slice(&bytes);
494
495        assert_eq!(source.len_u64(), 6);
496        assert_eq!(source.window_at(ByteRange::new(2, 3).unwrap()).unwrap(), &[2, 3, 4]);
497        assert_eq!(
498            source.window_at(ByteRange::new(4, 4).unwrap()),
499            Err(ReadAtError::RangeOutOfBounds { offset: 4, len: 4, source_len: 6 })
500        );
501    }
502
503    #[test]
504    fn read_backend_windows_partial_ranges() {
505        let bytes = [0, 1, 2, 3, 4, 5];
506        let source = ReadBackend::Slice(&bytes);
507
508        assert_eq!(source.window_at_partial(ByteRange::new(4, 4).unwrap()).unwrap(), &[4, 5]);
509        assert_eq!(
510            source.window_at_partial(ByteRange::new(6, 1).unwrap()),
511            Err(ReadAtError::OffsetOutOfBounds { offset: 6, source_len: 6 })
512        );
513    }
514
515    #[test]
516    fn read_backend_copies_exact_ranges() {
517        let bytes = [10, 11, 12, 13];
518        let source = ReadBackend::Slice(&bytes);
519        let mut out = [0u8; 2];
520
521        assert_eq!(source.read_at(ByteRange::new(1, 2).unwrap(), &mut out), Ok(2));
522        assert_eq!(out, [11, 12]);
523        assert_eq!(
524            source.read_at(ByteRange::new(1, 3).unwrap(), &mut out),
525            Err(ReadAtError::DestinationTooSmall { requested: 3, available: 2 })
526        );
527    }
528
529    #[test]
530    fn slice_backend_matches_read_backend_semantics() {
531        let bytes = [20, 21, 22, 23, 24];
532        let source = SliceBackend::new(&bytes);
533
534        assert_eq!(source.len_u64(), 5);
535        assert_eq!(source.window_at(ByteRange::new(1, 3).unwrap()).unwrap(), &[21, 22, 23]);
536
537        let mut copied = [0; 2];
538        assert_eq!(source.read_at(ByteRange::new(3, 2).unwrap(), &mut copied), Ok(2));
539        assert_eq!(copied, [23, 24]);
540    }
541
542    #[test]
543    fn file_backend_copies_exact_ranges_without_windows() {
544        use std::io::Write;
545
546        let path = std::env::temp_dir().join(format!(
547            "revelo-core-file-backend-{}-{}.bin",
548            std::process::id(),
549            "range"
550        ));
551        let mut file = std::fs::File::create(&path).unwrap();
552        file.write_all(&[50, 51, 52, 53, 54, 55]).unwrap();
553        file.sync_all().unwrap();
554        drop(file);
555
556        let source = FileBackend::open(&path).unwrap();
557        let mut copied = [0; 3];
558
559        assert_eq!(source.len_u64(), 6);
560        assert_eq!(source.read_at(ByteRange::new(2, 3).unwrap(), &mut copied), Ok(3));
561        assert_eq!(copied, [52, 53, 54]);
562        assert_eq!(
563            source.window_at(ByteRange::new(0, 1).unwrap()),
564            Err(ReadAtError::UnavailableWindow)
565        );
566        assert_eq!(
567            source.read_at(ByteRange::new(5, 2).unwrap(), &mut copied),
568            Err(ReadAtError::RangeOutOfBounds { offset: 5, len: 2, source_len: 6 })
569        );
570
571        std::fs::remove_file(path).unwrap();
572    }
573}