Skip to main content

oxideav_source/
data.rs

1//! Built-in `data:` driver — inline byte literals embedded in the URI.
2//!
3//! Implements RFC 2397 (`dataurl := "data:" [ mediatype ] [ ";base64" ] ","
4//! data`). The driver returns a [`BytesSource`] backed by an in-memory
5//! `Cursor` — no IO, no allocation past the payload, no external state.
6//!
7//! Useful for:
8//! - Fixture URIs embedded in tests or CLI flags without a temp file.
9//! - Single-shot transports where the entire payload fits in the URI
10//!   (small icons, calibration tones, RTP-payload trace dumps).
11//! - Configuration knobs that take a URI and would otherwise need a
12//!   sentinel like `--no-input` to mean "use these bytes literally".
13//!
14//! Grammar (RFC 2397 §3, abbreviated):
15//!
16//! ```text
17//! dataurl    = "data:" [ mediatype ] [ ";base64" ] "," data
18//! mediatype  = [ type "/" subtype ] *( ";" parameter )
19//! parameter  = attribute "=" value
20//! data       = *urlchar
21//! ```
22//!
23//! When `mediatype` is absent the RFC defaults it to
24//! `text/plain;charset=US-ASCII`. The driver does not interpret the
25//! media type — it only carries the bytes — but [`parse`] surfaces the
26//! parsed string so callers can route based on it.
27//!
28//! Encodings:
29//! - **`;base64`** present: payload is base64-decoded per RFC 4648 §4
30//!   ("standard" alphabet with `+` `/`). Whitespace in the payload is
31//!   tolerated and skipped. Padding is required to make the input
32//!   length a multiple of four.
33//! - Otherwise: payload is percent-decoded (`%HH` → byte `0xHH`).
34//!   Non-`%` bytes pass through unchanged.
35//!
36//! Clean-room note: RFC 2397 was read as the only reference. No
37//! external `data:` URL implementation was consulted.
38
39use std::io::Cursor;
40
41use oxideav_core::{BytesSource, Error, Result};
42
43use crate::uri;
44
45/// Parsed components of a `data:` URI.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct DataUri {
48    /// Media type, exactly as written between `data:` and the `,` (less
49    /// the trailing `;base64` marker if present). Empty string means
50    /// the URI used the RFC default of `text/plain;charset=US-ASCII`;
51    /// callers that care can apply that default themselves.
52    pub mediatype: String,
53    /// True iff the URI included the `;base64` marker.
54    pub base64: bool,
55    /// Decoded payload bytes.
56    pub data: Vec<u8>,
57}
58
59/// Parse a `data:` URI into its [`DataUri`] components.
60///
61/// Accepts both `data:,hello` and `data:image/png;base64,iVBORw...`
62/// shapes. Rejects URIs that lack the mandatory `,` separator.
63pub fn parse(uri_str: &str) -> Result<DataUri> {
64    let (scheme, rest) = uri::split(uri_str);
65    if scheme != "data" {
66        return Err(Error::invalid(format!(
67            "data driver invoked on non-data URI: {uri_str}"
68        )));
69    }
70    let comma = rest
71        .find(',')
72        .ok_or_else(|| Error::invalid("data: URI missing comma separator"))?;
73    let (header, payload) = rest.split_at(comma);
74    let payload = &payload[1..]; // skip ','
75
76    // Strip a trailing ";base64" marker (case-insensitive, like RFC 2397
77    // examples in §4 mix "base64" and "BASE64").
78    let (mediatype, base64) = if let Some(stripped) = strip_base64_suffix(header) {
79        (stripped, true)
80    } else {
81        (header, false)
82    };
83
84    let data = if base64 {
85        decode_base64(payload)?
86    } else {
87        percent_decode(payload)?
88    };
89
90    Ok(DataUri {
91        mediatype: mediatype.to_string(),
92        base64,
93        data,
94    })
95}
96
97/// Open a `data:` URI as a [`BytesSource`]. Equivalent to [`parse`]
98/// followed by wrapping the decoded bytes in a `Cursor`.
99pub fn open_data(uri_str: &str) -> Result<Box<dyn BytesSource>> {
100    let parsed = parse(uri_str)?;
101    Ok(Box::new(Cursor::new(parsed.data)))
102}
103
104/// If `header` ends with `;base64` (case-insensitive), return the prefix
105/// without that marker. Otherwise `None`.
106fn strip_base64_suffix(header: &str) -> Option<&str> {
107    // RFC 2397 §3 places ";base64" after any other parameters and just
108    // before the comma. We match the literal marker so we don't get
109    // confused by a parameter named `base64=…`.
110    let bytes = header.as_bytes();
111    const MARKER: &[u8] = b";base64";
112    if bytes.len() < MARKER.len() {
113        return None;
114    }
115    let tail = &bytes[bytes.len() - MARKER.len()..];
116    if tail.eq_ignore_ascii_case(MARKER) {
117        // SAFETY: we only sliced at an ASCII byte boundary.
118        Some(&header[..header.len() - MARKER.len()])
119    } else {
120        None
121    }
122}
123
124/// Decode RFC 3986 percent-encoded bytes. `%HH` → byte 0xHH; other
125/// bytes pass through. A `+` is **not** translated to space — that is a
126/// `application/x-www-form-urlencoded` convention, not the RFC 2397
127/// data-URI rule.
128fn percent_decode(s: &str) -> Result<Vec<u8>> {
129    let bytes = s.as_bytes();
130    let mut out = Vec::with_capacity(bytes.len());
131    let mut i = 0;
132    while i < bytes.len() {
133        let b = bytes[i];
134        if b == b'%' {
135            if i + 2 >= bytes.len() {
136                return Err(Error::invalid(format!(
137                    "data:// percent-encoding truncated at offset {i}"
138                )));
139            }
140            let hi = hex_nibble(bytes[i + 1]).ok_or_else(|| {
141                Error::invalid(format!(
142                    "data:// percent-encoding: non-hex digit {:?}",
143                    bytes[i + 1] as char
144                ))
145            })?;
146            let lo = hex_nibble(bytes[i + 2]).ok_or_else(|| {
147                Error::invalid(format!(
148                    "data:// percent-encoding: non-hex digit {:?}",
149                    bytes[i + 2] as char
150                ))
151            })?;
152            out.push((hi << 4) | lo);
153            i += 3;
154        } else {
155            out.push(b);
156            i += 1;
157        }
158    }
159    Ok(out)
160}
161
162fn hex_nibble(b: u8) -> Option<u8> {
163    match b {
164        b'0'..=b'9' => Some(b - b'0'),
165        b'a'..=b'f' => Some(b - b'a' + 10),
166        b'A'..=b'F' => Some(b - b'A' + 10),
167        _ => None,
168    }
169}
170
171/// Decode RFC 4648 §4 base64 (standard alphabet, with padding). Skips
172/// ASCII whitespace inside the payload so multi-line embedding works.
173fn decode_base64(s: &str) -> Result<Vec<u8>> {
174    // Strip whitespace into a side buffer first; the input length after
175    // stripping must be a multiple of 4 (with `=` padding accounted for).
176    let mut clean: Vec<u8> = Vec::with_capacity(s.len());
177    for &b in s.as_bytes() {
178        if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
179            continue;
180        }
181        clean.push(b);
182    }
183    if clean.len() % 4 != 0 {
184        return Err(Error::invalid(format!(
185            "data:// base64 payload length {} is not a multiple of 4",
186            clean.len()
187        )));
188    }
189    let mut out: Vec<u8> = Vec::with_capacity(clean.len() / 4 * 3);
190    let mut chunk = [0u8; 4];
191    let mut i = 0;
192    while i < clean.len() {
193        let mut pad = 0;
194        for j in 0..4 {
195            let b = clean[i + j];
196            if b == b'=' {
197                pad += 1;
198                chunk[j] = 0;
199            } else {
200                if pad > 0 {
201                    return Err(Error::invalid(
202                        "data:// base64 padding character before end of payload",
203                    ));
204                }
205                chunk[j] = b64_value(b).ok_or_else(|| {
206                    Error::invalid(format!("data:// base64: invalid character {:?}", b as char))
207                })?;
208            }
209        }
210        if pad > 2 {
211            return Err(Error::invalid(
212                "data:// base64: more than two padding characters in a group",
213            ));
214        }
215        // Padding is only legal in the final group.
216        if pad > 0 && i + 4 < clean.len() {
217            return Err(Error::invalid("data:// base64: padding before final group"));
218        }
219        let triple = (u32::from(chunk[0]) << 18)
220            | (u32::from(chunk[1]) << 12)
221            | (u32::from(chunk[2]) << 6)
222            | u32::from(chunk[3]);
223        out.push(((triple >> 16) & 0xff) as u8);
224        if pad < 2 {
225            out.push(((triple >> 8) & 0xff) as u8);
226        }
227        if pad < 1 {
228            out.push((triple & 0xff) as u8);
229        }
230        i += 4;
231    }
232    Ok(out)
233}
234
235fn b64_value(b: u8) -> Option<u8> {
236    // RFC 4648 §4 standard alphabet:
237    //   A-Z → 0-25, a-z → 26-51, 0-9 → 52-61, '+' → 62, '/' → 63
238    match b {
239        b'A'..=b'Z' => Some(b - b'A'),
240        b'a'..=b'z' => Some(b - b'a' + 26),
241        b'0'..=b'9' => Some(b - b'0' + 52),
242        b'+' => Some(62),
243        b'/' => Some(63),
244        _ => None,
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use std::io::Read;
251
252    use super::*;
253
254    #[test]
255    fn rfc2397_example_inline_text() {
256        // RFC 2397 §4 example: "data:,A%20brief%20note"
257        let p = parse("data:,A%20brief%20note").unwrap();
258        assert_eq!(p.mediatype, "");
259        assert!(!p.base64);
260        assert_eq!(p.data, b"A brief note");
261    }
262
263    #[test]
264    fn rfc2397_example_base64_image_prefix() {
265        // RFC 2397 §4 references an image/gif base64. We assert the
266        // parser splits header from payload and decodes a known base64
267        // prefix correctly. "Hello" → "SGVsbG8=".
268        let p = parse("data:image/gif;base64,SGVsbG8=").unwrap();
269        assert_eq!(p.mediatype, "image/gif");
270        assert!(p.base64);
271        assert_eq!(p.data, b"Hello");
272    }
273
274    #[test]
275    fn empty_mediatype_no_payload() {
276        let p = parse("data:,").unwrap();
277        assert_eq!(p.mediatype, "");
278        assert_eq!(p.data, b"");
279    }
280
281    #[test]
282    fn mediatype_with_parameter() {
283        // RFC 2397 example: data:text/plain;charset=US-ASCII,xyz
284        let p = parse("data:text/plain;charset=US-ASCII,abc").unwrap();
285        assert_eq!(p.mediatype, "text/plain;charset=US-ASCII");
286        assert!(!p.base64);
287        assert_eq!(p.data, b"abc");
288    }
289
290    #[test]
291    fn base64_marker_case_insensitive() {
292        let p = parse("data:application/octet-stream;BASE64,SGVsbG8=").unwrap();
293        assert!(p.base64);
294        assert_eq!(p.data, b"Hello");
295    }
296
297    #[test]
298    fn base64_with_internal_whitespace() {
299        // Multi-line embedding tolerance (data: URIs in source files).
300        let p = parse("data:;base64,SG Vs\nbG8=").unwrap();
301        assert_eq!(p.data, b"Hello");
302    }
303
304    #[test]
305    fn percent_decode_high_byte() {
306        let p = parse("data:,%FF%00%7E").unwrap();
307        assert_eq!(p.data, [0xff, 0x00, 0x7e]);
308    }
309
310    #[test]
311    fn missing_comma_rejected() {
312        let r = parse("data:text/plain;base64");
313        assert!(r.is_err());
314    }
315
316    #[test]
317    fn truncated_percent_rejected() {
318        let r = parse("data:,%F");
319        assert!(r.is_err());
320    }
321
322    #[test]
323    fn bad_hex_rejected() {
324        let r = parse("data:,%ZZ");
325        assert!(r.is_err());
326    }
327
328    #[test]
329    fn base64_bad_length_rejected() {
330        // 3 chars (after whitespace strip) is not a multiple of 4.
331        let r = parse("data:;base64,SGV");
332        assert!(r.is_err());
333    }
334
335    #[test]
336    fn base64_padding_in_middle_rejected() {
337        // Padding may only occur in the final 4-char group.
338        let r = parse("data:;base64,SGVs=GVs");
339        assert!(r.is_err());
340    }
341
342    #[test]
343    fn base64_invalid_char_rejected() {
344        let r = parse("data:;base64,SG!s");
345        assert!(r.is_err());
346    }
347
348    #[test]
349    fn wrong_scheme_rejected() {
350        let r = parse("file:///tmp/x");
351        assert!(r.is_err());
352        let r = open_data("mem://x");
353        assert!(r.is_err());
354    }
355
356    #[test]
357    fn open_data_returns_readable_cursor() {
358        let mut r = open_data("data:,hello").unwrap();
359        let mut buf = Vec::new();
360        r.read_to_end(&mut buf).unwrap();
361        assert_eq!(buf, b"hello");
362    }
363
364    #[test]
365    fn base64_full_alphabet_roundtrip() {
366        // Encodes 0..255 in 6-bit groups: 256 bytes → 344 base64 chars
367        // + 0 padding (256 % 3 == 1 → 2 pad). 256 bytes / 3 = 85 r 1 →
368        // 86 groups → 344 chars; one group has two `=`.
369        let payload: Vec<u8> = (0u8..=255).collect();
370        // Hand-encode with our own encoder to keep this self-contained.
371        let encoded = encode_b64(&payload);
372        let uri = format!("data:application/octet-stream;base64,{encoded}");
373        let parsed = parse(&uri).unwrap();
374        assert_eq!(parsed.data, payload);
375    }
376
377    /// Test-helper encoder. Not exposed publicly — the driver decodes
378    /// only. Kept inside `tests` so production code does not grow an
379    /// encoder it does not need.
380    fn encode_b64(input: &[u8]) -> String {
381        const ALPHA: &[u8; 64] =
382            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
383        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
384        let mut i = 0;
385        while i + 3 <= input.len() {
386            let b0 = input[i];
387            let b1 = input[i + 1];
388            let b2 = input[i + 2];
389            out.push(ALPHA[(b0 >> 2) as usize] as char);
390            out.push(ALPHA[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
391            out.push(ALPHA[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
392            out.push(ALPHA[(b2 & 0x3f) as usize] as char);
393            i += 3;
394        }
395        match input.len() - i {
396            0 => {}
397            1 => {
398                let b0 = input[i];
399                out.push(ALPHA[(b0 >> 2) as usize] as char);
400                out.push(ALPHA[((b0 & 0x03) << 4) as usize] as char);
401                out.push('=');
402                out.push('=');
403            }
404            2 => {
405                let b0 = input[i];
406                let b1 = input[i + 1];
407                out.push(ALPHA[(b0 >> 2) as usize] as char);
408                out.push(ALPHA[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
409                out.push(ALPHA[((b1 & 0x0f) << 2) as usize] as char);
410                out.push('=');
411            }
412            _ => unreachable!(),
413        }
414        out
415    }
416}