firebase_rs_sdk/storage/
string.rs

1use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
2use base64::Engine as _;
3use percent_encoding::percent_decode_str;
4
5use crate::storage::error::{invalid_argument, StorageResult};
6
7/// Mirrors the Firebase Web SDK string upload formats.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum StringFormat {
10    /// Interpret the input as UTF-8 text.
11    Raw,
12    /// Interpret the input as standard base64 encoded data.
13    Base64,
14    /// Interpret the input as base64url encoded data.
15    Base64Url,
16    /// Interpret the input as a data URL (e.g. `data:image/png;base64,...`).
17    DataUrl,
18}
19
20impl Default for StringFormat {
21    fn default() -> Self {
22        StringFormat::Raw
23    }
24}
25
26#[derive(Debug)]
27pub struct PreparedString {
28    pub bytes: Vec<u8>,
29    pub content_type: Option<String>,
30}
31
32impl PreparedString {
33    fn new(bytes: Vec<u8>, content_type: Option<String>) -> Self {
34        Self {
35            bytes,
36            content_type,
37        }
38    }
39}
40
41pub fn prepare_string_upload(value: &str, format: StringFormat) -> StorageResult<PreparedString> {
42    match format {
43        StringFormat::Raw => Ok(PreparedString::new(value.as_bytes().to_vec(), None)),
44        StringFormat::Base64 => decode_base64(value),
45        StringFormat::Base64Url => decode_base64_url(value),
46        StringFormat::DataUrl => decode_data_url(value),
47    }
48}
49
50fn decode_base64(value: &str) -> StorageResult<PreparedString> {
51    STANDARD
52        .decode(value)
53        .map(|bytes| PreparedString::new(bytes, None))
54        .map_err(|err| invalid_argument(format!("Invalid base64 data: {err}")))
55}
56
57fn decode_base64_url(value: &str) -> StorageResult<PreparedString> {
58    let sanitized = value.trim_end_matches('=');
59    URL_SAFE_NO_PAD
60        .decode(sanitized)
61        .map(|bytes| PreparedString::new(bytes, None))
62        .map_err(|err| invalid_argument(format!("Invalid base64url data: {err}")))
63}
64
65fn decode_data_url(value: &str) -> StorageResult<PreparedString> {
66    if !value.starts_with("data:") {
67        return Err(invalid_argument(
68            "Data URL must start with the 'data:' scheme.",
69        ));
70    }
71
72    let comma = value.find(',').ok_or_else(|| {
73        invalid_argument("Data URL must contain a comma separating metadata and data segments.")
74    })?;
75
76    let metadata = &value[5..comma];
77    let data_part = &value[comma + 1..];
78
79    let (is_base64, content_type) = if metadata.is_empty() {
80        (false, None)
81    } else if let Some(stripped) = metadata.strip_suffix(";base64") {
82        let content_type = stripped.trim();
83        let content_type = if content_type.is_empty() {
84            None
85        } else {
86            Some(content_type.to_string())
87        };
88        (true, content_type)
89    } else {
90        let content_type = metadata.trim();
91        let content_type = if content_type.is_empty() {
92            None
93        } else {
94            Some(content_type.to_string())
95        };
96        (false, content_type)
97    };
98
99    let bytes = if is_base64 {
100        STANDARD
101            .decode(data_part)
102            .map_err(|err| invalid_argument(format!("Invalid base64 data URL: {err}")))?
103    } else {
104        percent_decode_str(data_part)
105            .decode_utf8()
106            .map_err(|_| invalid_argument("Data URL payload must be valid percent-encoded UTF-8."))?
107            .into_owned()
108            .into_bytes()
109    };
110
111    Ok(PreparedString::new(bytes, content_type))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn raw_returns_utf8_bytes() {
120        let prepared = prepare_string_upload("hello", StringFormat::Raw).unwrap();
121        assert_eq!(prepared.bytes, b"hello");
122        assert!(prepared.content_type.is_none());
123    }
124
125    #[test]
126    fn base64_decodes_to_bytes() {
127        let prepared = prepare_string_upload("aGVsbG8=", StringFormat::Base64).unwrap();
128        assert_eq!(prepared.bytes, b"hello");
129    }
130
131    #[test]
132    fn base64_url_allows_paddingless_values() {
133        let prepared = prepare_string_upload("aGVsbG8", StringFormat::Base64Url).unwrap();
134        assert_eq!(prepared.bytes, b"hello");
135    }
136
137    #[test]
138    fn data_url_extracts_content_type() {
139        let prepared =
140            prepare_string_upload("data:text/plain;base64,aGVsbG8=", StringFormat::DataUrl)
141                .unwrap();
142        assert_eq!(prepared.bytes, b"hello");
143        assert_eq!(prepared.content_type.as_deref(), Some("text/plain"));
144    }
145
146    #[test]
147    fn data_url_percent_encoded() {
148        let prepared = prepare_string_upload("data:,Hello%20World", StringFormat::DataUrl).unwrap();
149        assert_eq!(prepared.bytes, b"Hello World");
150        assert!(prepared.content_type.is_none());
151    }
152}