Skip to main content

oxideav_source/
concat.rs

1//! Built-in `concat:` driver — concatenate multiple in-process sub-sources
2//! into one seekable byte stream.
3//!
4//! The scheme has **no on-wire spec**; it follows the de-facto
5//! `concat:a|b|c` shape (a `|`-separated list of segments after the
6//! `concat:` prefix). The opened segments are presented as a single
7//! logical stream whose length is the sum of the segment lengths, with
8//! `Read` walking segment boundaries transparently and `Seek` resolving
9//! an absolute offset to `(segment, intra-offset)`.
10//!
11//! Each segment may be one of the same scheme set the [`slice:`](crate::open_slice)
12//! driver accepts as its inner URI:
13//!
14//! - `file://` and bare paths (delegates to [`open_file`]).
15//! - `mem://<id>` (delegates to [`open_mem`]).
16//! - `data:[<mediatype>][;base64],<bytes>` (delegates to [`open_data`]).
17//! - `slice:<offset>+<length>!<inner-uri>` (delegates to [`open_slice`]).
18//! - `concat:` itself is **not** allowed as a segment — a nested
19//!   `concat:` would have to embed unescaped `|` separators, which the
20//!   outer split would shred. Use a single flattened list.
21//!
22//! Grammar (informal):
23//!
24//! ```text
25//! concaturl = "concat:" segment *( "|" segment )
26//! segment   = <bare path, file://, mem://, data:, or slice: URI with no embedded '|'>
27//! ```
28//!
29//! At least one non-empty segment is required. An empty segment (e.g. a
30//! trailing `|` or `a||b`) is rejected so a typo does not silently
31//! collapse to fewer inputs. A literal `|` inside an inner URI is not
32//! supported; segments are split on the first level of `|` only.
33//!
34//! Each segment's byte length is captured at open time via
35//! `Seek::seek(SeekFrom::End(0))`, so the composite supports
36//! `SeekFrom::End` and reports a stable length. Segments are assumed not
37//! to change size while the composite is open; if one is truncated under
38//! us a `Read` near its tail surfaces the short read from the underlying
39//! source like any other reader.
40//!
41//! Clean-room note: only the public in-process openers and the standard
42//! `Read`/`Seek` traits were used. No external `concat:` implementation
43//! was consulted.
44
45use std::io::{self, Read, Seek, SeekFrom};
46
47use oxideav_core::{BytesSource, Error, Result};
48
49use crate::data::open_data;
50use crate::file::open_file;
51use crate::mem::open_mem;
52use crate::slice::open_slice;
53use crate::uri;
54
55/// A composite [`BytesSource`] that reads several sub-sources in order as
56/// one contiguous stream.
57///
58/// Construction captures each segment's length (via a seek to its end),
59/// builds the cumulative-offset table, and rewinds the first segment to
60/// its start. `Read` and `Seek` then operate on the virtual address
61/// space `[0, total_len)`.
62struct ConcatSource {
63    /// Open sub-sources, in concatenation order.
64    parts: Vec<Box<dyn BytesSource>>,
65    /// `starts[i]` is the absolute offset at which `parts[i]` begins;
66    /// `starts[parts.len()]` is the total length. Monotonically
67    /// non-decreasing (a zero-length part repeats the previous start).
68    starts: Vec<u64>,
69    /// Current absolute read position in `[0, total_len]`.
70    pos: u64,
71}
72
73impl ConcatSource {
74    /// Build a composite from already-opened, individually-seekable
75    /// sub-sources. Each is seeked to its end to learn its length, then
76    /// the first is rewound to offset 0 so reads start from the front.
77    fn new(mut parts: Vec<Box<dyn BytesSource>>) -> Result<Self> {
78        let mut starts = Vec::with_capacity(parts.len() + 1);
79        let mut acc: u64 = 0;
80        for part in parts.iter_mut() {
81            starts.push(acc);
82            let len = part.seek(SeekFrom::End(0))?;
83            acc = acc
84                .checked_add(len)
85                .ok_or_else(|| Error::invalid("concat: total length overflows u64"))?;
86        }
87        starts.push(acc);
88        // Rewind the first segment so a fresh composite reads from byte 0
89        // without an explicit seek by the caller.
90        if let Some(first) = parts.first_mut() {
91            first.seek(SeekFrom::Start(0))?;
92        }
93        Ok(Self {
94            parts,
95            starts,
96            pos: 0,
97        })
98    }
99
100    /// Total length of the composite stream.
101    fn total_len(&self) -> u64 {
102        *self
103            .starts
104            .last()
105            .expect("starts always has a trailing total")
106    }
107
108    /// Index of the segment containing absolute offset `pos`, or `None`
109    /// if `pos` is at or past the end. Zero-length segments are skipped:
110    /// the returned segment always has room for at least one byte.
111    fn segment_for(&self, pos: u64) -> Option<usize> {
112        if pos >= self.total_len() {
113            return None;
114        }
115        // starts[i] <= pos < starts[i+1]; pick the segment whose half-open
116        // range contains pos. Linear scan — segment counts are tiny.
117        (0..self.parts.len()).find(|&i| pos >= self.starts[i] && pos < self.starts[i + 1])
118    }
119}
120
121impl Read for ConcatSource {
122    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
123        if buf.is_empty() {
124            return Ok(0);
125        }
126        let idx = match self.segment_for(self.pos) {
127            Some(i) => i,
128            None => return Ok(0), // at or past EOF
129        };
130        // Bytes remaining in this segment from the current position.
131        let seg_start = self.starts[idx];
132        let seg_end = self.starts[idx + 1];
133        let intra = self.pos - seg_start;
134        let remaining_in_seg = (seg_end - self.pos) as usize;
135        let want = buf.len().min(remaining_in_seg);
136
137        // Position the underlying segment, then read up to `want` bytes.
138        let part = &mut self.parts[idx];
139        part.seek(SeekFrom::Start(intra))?;
140        let n = part.read(&mut buf[..want])?;
141        self.pos += n as u64;
142        Ok(n)
143    }
144}
145
146impl Seek for ConcatSource {
147    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
148        let total = self.total_len();
149        let new_pos = match from {
150            SeekFrom::Start(off) => off,
151            SeekFrom::End(off) => add_signed(total, off)?,
152            SeekFrom::Current(off) => add_signed(self.pos, off)?,
153        };
154        self.pos = new_pos;
155        Ok(self.pos)
156    }
157}
158
159/// Add a signed offset to an unsigned base, mapping under/overflow to an
160/// `InvalidInput` error (matching `io::Cursor` seek semantics: seeking
161/// before byte 0 is an error, seeking past the end is allowed).
162fn add_signed(base: u64, off: i64) -> io::Result<u64> {
163    let result = if off >= 0 {
164        base.checked_add(off as u64)
165    } else {
166        base.checked_sub(off.unsigned_abs())
167    };
168    result.ok_or_else(|| {
169        io::Error::new(
170            io::ErrorKind::InvalidInput,
171            "concat: seek resolves to a negative or overflowing position",
172        )
173    })
174}
175
176/// Split the `concat:` payload into its `|`-separated segment list.
177/// Returns an error if any segment is empty (so `a||b` or a trailing
178/// `|` is caught rather than silently dropped).
179fn segments(rest: &str) -> Result<Vec<&str>> {
180    let parts: Vec<&str> = rest.split('|').collect();
181    if parts.iter().any(|s| s.is_empty()) {
182        return Err(Error::invalid(format!(
183            "concat: URI has an empty segment: {rest:?}"
184        )));
185    }
186    Ok(parts)
187}
188
189/// Resolve a single `concat:` segment by dispatching to one of the
190/// bundled in-process openers. Mirrors the dispatch surface of the
191/// `slice:` driver: `file://` / bare paths, `mem://`, `data:`, and
192/// `slice:`. A `concat:` segment is rejected — see the module docs for
193/// the rationale (nested `concat:` would re-enter the outer `|` split).
194fn open_segment(seg: &str) -> Result<Box<dyn BytesSource>> {
195    let (seg_scheme, _) = uri::split(seg);
196    match seg_scheme {
197        "file" => open_file(seg),
198        "mem" => open_mem(seg),
199        "data" => open_data(seg),
200        "slice" => open_slice(seg),
201        "concat" => Err(Error::invalid(format!(
202            "concat: segment {seg:?} is itself a concat: URI; nesting concat is not supported \
203             because the outer '|' split would shred the inner segment list"
204        ))),
205        other => Err(Error::invalid(format!(
206            "concat: segment {seg:?} uses unsupported scheme {other:?}; \
207             only file/mem/data/slice are accepted"
208        ))),
209    }
210}
211
212/// Open a `concat:<a>|<b>|…` URI as a single [`BytesSource`] that reads
213/// the segments back-to-back. Each segment may be a bare path, a
214/// `file://` URL, a `mem://<id>` reference, a `data:` literal, or a
215/// `slice:` URI.
216pub fn open_concat(uri_str: &str) -> Result<Box<dyn BytesSource>> {
217    let (scheme, rest) = uri::split(uri_str);
218    if scheme != "concat" {
219        return Err(Error::invalid(format!(
220            "concat driver invoked on non-concat URI: {uri_str}"
221        )));
222    }
223    if rest.is_empty() {
224        return Err(Error::invalid("concat: URI requires at least one segment"));
225    }
226    let segs = segments(rest)?;
227    let mut parts: Vec<Box<dyn BytesSource>> = Vec::with_capacity(segs.len());
228    for seg in segs {
229        parts.push(open_segment(seg)?);
230    }
231    Ok(Box::new(ConcatSource::new(parts)?))
232}
233
234#[cfg(test)]
235mod tests {
236    use std::io::{Read, Seek, SeekFrom, Write};
237
238    use crate::mem;
239
240    use super::*;
241
242    /// Write `bytes` to a uniquely-named temp file and return its path.
243    fn temp_file(bytes: &[u8]) -> std::path::PathBuf {
244        use std::sync::atomic::{AtomicU64, Ordering};
245        static N: AtomicU64 = AtomicU64::new(0);
246        let mut path = std::env::temp_dir();
247        let pid = std::process::id();
248        let n = N.fetch_add(1, Ordering::Relaxed);
249        path.push(format!("oxideav-concat-test-{pid}-{n}.bin"));
250        let mut f = std::fs::File::create(&path).unwrap();
251        f.write_all(bytes).unwrap();
252        f.flush().unwrap();
253        path
254    }
255
256    fn uri_for(paths: &[&std::path::Path]) -> String {
257        let joined: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
258        format!("concat:{}", joined.join("|"))
259    }
260
261    #[test]
262    fn two_files_read_back_to_back() {
263        let a = temp_file(b"Hello, ");
264        let b = temp_file(b"world!");
265        let mut r = open_concat(&uri_for(&[&a, &b])).unwrap();
266        let mut buf = Vec::new();
267        r.read_to_end(&mut buf).unwrap();
268        assert_eq!(buf, b"Hello, world!");
269        std::fs::remove_file(a).ok();
270        std::fs::remove_file(b).ok();
271    }
272
273    #[test]
274    fn three_segments() {
275        let a = temp_file(b"AAA");
276        let b = temp_file(b"BB");
277        let c = temp_file(b"CCCC");
278        let mut r = open_concat(&uri_for(&[&a, &b, &c])).unwrap();
279        let mut buf = Vec::new();
280        r.read_to_end(&mut buf).unwrap();
281        assert_eq!(buf, b"AAABBCCCC");
282        for p in [a, b, c] {
283            std::fs::remove_file(p).ok();
284        }
285    }
286
287    #[test]
288    fn small_buffer_reads_cross_boundary() {
289        // Force the boundary-walking path: read 1 byte at a time.
290        let a = temp_file(b"XY");
291        let b = temp_file(b"Z");
292        let mut r = open_concat(&uri_for(&[&a, &b])).unwrap();
293        let mut out = Vec::new();
294        let mut byte = [0u8; 1];
295        loop {
296            let n = r.read(&mut byte).unwrap();
297            if n == 0 {
298                break;
299            }
300            out.push(byte[0]);
301        }
302        assert_eq!(out, b"XYZ");
303        std::fs::remove_file(a).ok();
304        std::fs::remove_file(b).ok();
305    }
306
307    #[test]
308    fn seek_end_reports_total_length() {
309        let a = temp_file(b"12345");
310        let b = temp_file(b"678");
311        let mut r = open_concat(&uri_for(&[&a, &b])).unwrap();
312        let end = r.seek(SeekFrom::End(0)).unwrap();
313        assert_eq!(end, 8);
314        std::fs::remove_file(a).ok();
315        std::fs::remove_file(b).ok();
316    }
317
318    #[test]
319    fn seek_into_second_segment() {
320        let a = temp_file(b"abcd"); // offsets 0..4
321        let b = temp_file(b"EFGH"); // offsets 4..8
322        let mut r = open_concat(&uri_for(&[&a, &b])).unwrap();
323        r.seek(SeekFrom::Start(5)).unwrap(); // second byte of segment b
324        let mut byte = [0u8; 1];
325        r.read_exact(&mut byte).unwrap();
326        assert_eq!(byte[0], b'F');
327        std::fs::remove_file(a).ok();
328        std::fs::remove_file(b).ok();
329    }
330
331    #[test]
332    fn seek_across_boundary_then_read() {
333        let a = temp_file(b"abc"); // 0..3
334        let b = temp_file(b"defg"); // 3..7
335        let mut r = open_concat(&uri_for(&[&a, &b])).unwrap();
336        r.seek(SeekFrom::Start(2)).unwrap();
337        let mut buf = [0u8; 4]; // spans last byte of a + first 3 of b
338        r.read_exact(&mut buf).unwrap();
339        assert_eq!(&buf, b"cdef");
340        std::fs::remove_file(a).ok();
341        std::fs::remove_file(b).ok();
342    }
343
344    #[test]
345    fn seek_current_relative() {
346        let a = temp_file(b"0123456789");
347        let mut r = open_concat(&uri_for(&[&a])).unwrap();
348        r.seek(SeekFrom::Start(3)).unwrap();
349        let p = r.seek(SeekFrom::Current(2)).unwrap();
350        assert_eq!(p, 5);
351        let mut byte = [0u8; 1];
352        r.read_exact(&mut byte).unwrap();
353        assert_eq!(byte[0], b'5');
354        std::fs::remove_file(a).ok();
355    }
356
357    #[test]
358    fn seek_before_zero_errors() {
359        let a = temp_file(b"abc");
360        let mut r = open_concat(&uri_for(&[&a])).unwrap();
361        let res = r.seek(SeekFrom::Current(-1));
362        assert!(res.is_err());
363        std::fs::remove_file(a).ok();
364    }
365
366    #[test]
367    fn read_at_eof_returns_zero() {
368        let a = temp_file(b"hi");
369        let mut r = open_concat(&uri_for(&[&a])).unwrap();
370        r.seek(SeekFrom::End(0)).unwrap();
371        let mut buf = [0u8; 4];
372        assert_eq!(r.read(&mut buf).unwrap(), 0);
373        std::fs::remove_file(a).ok();
374    }
375
376    #[test]
377    fn empty_segment_rejected() {
378        assert!(open_concat("concat:a||b").is_err());
379        assert!(open_concat("concat:a|").is_err());
380        assert!(open_concat("concat:|a").is_err());
381    }
382
383    #[test]
384    fn no_segments_rejected() {
385        assert!(open_concat("concat:").is_err());
386    }
387
388    #[test]
389    fn wrong_scheme_rejected() {
390        assert!(open_concat("file:///tmp/x").is_err());
391        assert!(open_concat("mem://x").is_err());
392    }
393
394    #[test]
395    fn file_url_segment_accepted() {
396        let a = temp_file(b"pre-");
397        let b = temp_file(b"post");
398        let uri = format!("concat:file://{}|file://{}", a.display(), b.display());
399        let mut r = open_concat(&uri).unwrap();
400        let mut buf = Vec::new();
401        r.read_to_end(&mut buf).unwrap();
402        assert_eq!(buf, b"pre-post");
403        std::fs::remove_file(a).ok();
404        std::fs::remove_file(b).ok();
405    }
406
407    #[test]
408    fn missing_file_segment_errors() {
409        let a = temp_file(b"ok");
410        let uri = format!("concat:{}|/no/such/path/xyzzy-oxideav", a.display());
411        assert!(open_concat(&uri).is_err());
412        std::fs::remove_file(a).ok();
413    }
414
415    #[test]
416    fn empty_file_segment_is_transparent() {
417        // A zero-length middle segment must not break boundary math.
418        let a = temp_file(b"AB");
419        let empty = temp_file(b"");
420        let c = temp_file(b"CD");
421        let mut r = open_concat(&uri_for(&[&a, &empty, &c])).unwrap();
422        let mut buf = Vec::new();
423        r.read_to_end(&mut buf).unwrap();
424        assert_eq!(buf, b"ABCD");
425        for p in [a, empty, c] {
426            std::fs::remove_file(p).ok();
427        }
428    }
429
430    #[test]
431    fn data_uri_segment_accepted() {
432        // Two inline literals: "Hello, " and "world!".
433        let mut r = open_concat("concat:data:,Hello%2C%20|data:,world%21").unwrap();
434        let mut buf = Vec::new();
435        r.read_to_end(&mut buf).unwrap();
436        assert_eq!(buf, b"Hello, world!");
437    }
438
439    #[test]
440    fn mem_segment_accepted() {
441        mem::put("concat-r184-a", b"AAA".to_vec());
442        mem::put("concat-r184-b", b"BB".to_vec());
443        let mut r = open_concat("concat:mem://concat-r184-a|mem://concat-r184-b").unwrap();
444        let mut buf = Vec::new();
445        r.read_to_end(&mut buf).unwrap();
446        assert_eq!(buf, b"AAABB");
447        mem::remove("concat-r184-a");
448        mem::remove("concat-r184-b");
449    }
450
451    #[test]
452    fn slice_segment_accepted() {
453        // Slice a mem buffer down to [2, 5) then concat with a file.
454        mem::put("concat-r184-slc", b"abcdefgh".to_vec());
455        let f = temp_file(b"XYZ");
456        let uri = format!("concat:slice:2+3!mem://concat-r184-slc|{}", f.display());
457        let mut r = open_concat(&uri).unwrap();
458        let mut buf = Vec::new();
459        r.read_to_end(&mut buf).unwrap();
460        assert_eq!(buf, b"cdeXYZ");
461        mem::remove("concat-r184-slc");
462        std::fs::remove_file(f).ok();
463    }
464
465    #[test]
466    fn mixed_schemes_concat_in_order() {
467        // file + mem + data, with a cross-boundary seek to prove the
468        // composite address space behaves regardless of underlying scheme.
469        mem::put("concat-r184-mix", b"MID".to_vec());
470        let head = temp_file(b"HEAD"); // 4 bytes, offsets 0..4
471                                       // mem        : 3 bytes, offsets 4..7
472                                       // data:,TAIL : 4 bytes, offsets 7..11
473        let uri = format!("concat:{}|mem://concat-r184-mix|data:,TAIL", head.display());
474        let mut r = open_concat(&uri).unwrap();
475        let total = r.seek(SeekFrom::End(0)).unwrap();
476        assert_eq!(total, 11);
477        r.seek(SeekFrom::Start(3)).unwrap(); // last byte of HEAD
478        let mut buf = vec![0u8; 6]; // "DMIDTA" — spans 3 segments
479        r.read_exact(&mut buf).unwrap();
480        assert_eq!(&buf, b"DMIDTA");
481        mem::remove("concat-r184-mix");
482        std::fs::remove_file(head).ok();
483    }
484
485    #[test]
486    fn nested_concat_segment_rejected() {
487        // A concat:-as-segment cannot be expressed unambiguously inside
488        // an outer concat: URI because the '|' split would shred the
489        // inner segment list. Reject explicitly.
490        let res = open_concat("concat:concat:a|b|c");
491        let err = res.err().expect("nested concat: must be rejected");
492        let msg = err.to_string();
493        assert!(
494            msg.contains("nesting concat") || msg.contains("not supported"),
495            "expected nesting-concat rejection, got {msg}"
496        );
497    }
498
499    #[test]
500    fn unsupported_inner_scheme_rejected() {
501        // http:// segments aren't dispatchable without registry context;
502        // mirror the slice: driver's same rejection.
503        let res = open_concat("concat:http://example.com/a|http://example.com/b");
504        assert!(res.is_err());
505    }
506}