Skip to main content

ferrin_message/
data_url.rs

1//! RFC 2397 `data:` URL parsing.
2//!
3//! Rules: the scheme is matched case-insensitively; the first comma separates
4//! the header from the payload; a `base64` parameter (case-insensitive) marks
5//! a base64 payload, which is percent-decoded, stripped of ASCII whitespace
6//! and decoded with padding optional; other payloads are percent-decoded; an
7//! empty media type defaults to `text/plain;charset=US-ASCII`; media type
8//! parameters other than `base64` are kept.
9
10use base64::Engine;
11use base64::alphabet::STANDARD;
12use base64::engine::DecodePaddingMode;
13use base64::engine::GeneralPurpose;
14use base64::engine::GeneralPurposeConfig;
15use bytes::Bytes;
16use ferrin_spec::MediaType;
17use percent_encoding::percent_decode_str;
18
19use crate::error::InvalidDataContentError;
20
21/// Media type used when a data URL omits it.
22pub const DEFAULT_MEDIA_TYPE: &str = "text/plain;charset=US-ASCII";
23
24const LENIENT_BASE64: GeneralPurpose = GeneralPurpose::new(
25    &STANDARD,
26    GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
27);
28
29/// A parsed `data:` URL.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct DataUrl {
32    /// Media type including any parameters other than `base64`.
33    pub media_type: MediaType,
34    /// Decoded payload.
35    pub data: Bytes,
36    /// Whether the payload was base64-encoded.
37    pub is_base64: bool,
38}
39
40/// Returns `true` when `input` starts with the `data:` scheme.
41#[must_use]
42pub fn is_data_url(input: &str) -> bool {
43    input.len() >= 5 && input.as_bytes()[..5].eq_ignore_ascii_case(b"data:")
44}
45
46/// Parses a `data:` URL.
47///
48/// # Errors
49///
50/// Returns [`InvalidDataContentError`] when the scheme is missing, the URL
51/// has no comma, or a base64 payload does not decode.
52pub fn parse(input: &str) -> Result<DataUrl, InvalidDataContentError> {
53    if !is_data_url(input) {
54        return Err(InvalidDataContentError::new(
55            "data url must start with `data:`",
56        ));
57    }
58    let rest = &input[5..];
59    let Some((header, payload)) = rest.split_once(',') else {
60        return Err(InvalidDataContentError::new(
61            "data url has no `,` separator",
62        ));
63    };
64
65    let mut is_base64 = false;
66    let mut media_type = String::new();
67    for (index, segment) in header.split(';').enumerate() {
68        let segment = segment.trim();
69        if index == 0 {
70            media_type.push_str(segment);
71        } else if segment.eq_ignore_ascii_case("base64") {
72            is_base64 = true;
73        } else if !segment.is_empty() {
74            if media_type.is_empty() {
75                media_type.push_str("text/plain");
76            }
77            media_type.push(';');
78            media_type.push_str(segment);
79        }
80    }
81    if media_type.is_empty() {
82        media_type.push_str(DEFAULT_MEDIA_TYPE);
83    }
84
85    let decoded = percent_decode_str(payload).collect::<Vec<u8>>();
86    let data = if is_base64 {
87        let compact: Vec<u8> = decoded
88            .into_iter()
89            .filter(|byte| !byte.is_ascii_whitespace())
90            .collect();
91        LENIENT_BASE64
92            .decode(compact)
93            .map(Bytes::from)
94            .map_err(|error| {
95                InvalidDataContentError::new("data url payload is not valid base64")
96                    .with_cause(error)
97            })?
98    } else {
99        Bytes::from(decoded)
100    };
101
102    Ok(DataUrl {
103        media_type: MediaType::new(media_type),
104        data,
105        is_base64,
106    })
107}