firebase_rs_sdk/storage/
string.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum StringFormat {
10 Raw,
12 Base64,
14 Base64Url,
16 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}