firebase_rs_sdk/storage/
location.rs1use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
2use url::Url;
3
4use crate::storage::constants::DEFAULT_HOST;
5use crate::storage::error::{invalid_default_bucket, invalid_url, StorageResult};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Location {
9 bucket: String,
10 path: String,
11}
12
13impl Location {
14 pub fn new(bucket: impl Into<String>, path: impl Into<String>) -> Self {
15 let bucket = bucket.into();
16 let mut path = path.into();
17 if !path.is_empty() {
18 path = path.trim_start_matches('/').to_string();
19 path = path.trim_end_matches('/').to_string();
20 }
21 Self { bucket, path }
22 }
23
24 pub fn bucket(&self) -> &str {
25 &self.bucket
26 }
27
28 pub fn path(&self) -> &str {
29 &self.path
30 }
31
32 pub fn is_root(&self) -> bool {
33 self.path.is_empty()
34 }
35
36 pub fn full_server_url(&self) -> String {
37 format!(
38 "/b/{}/o/{}",
39 utf8_percent_encode(&self.bucket, NON_ALPHANUMERIC),
40 utf8_percent_encode(&self.path, NON_ALPHANUMERIC)
41 )
42 }
43
44 pub fn bucket_only_server_url(&self) -> String {
45 format!(
46 "/b/{}/o",
47 utf8_percent_encode(&self.bucket, NON_ALPHANUMERIC)
48 )
49 }
50
51 pub fn from_bucket_spec(bucket_spec: &str, host: &str) -> StorageResult<Self> {
52 match Self::from_url(bucket_spec, host) {
53 Ok(location) if location.is_root() => Ok(location),
54 Ok(_) => Err(invalid_default_bucket(bucket_spec)),
55 Err(_) => Ok(Self::new(bucket_spec, "")),
56 }
57 }
58
59 pub fn from_url(url: &str, host: &str) -> StorageResult<Self> {
60 if let Some(rest) = url.strip_prefix("gs://") {
61 return Self::from_gs_url(rest);
62 }
63
64 if url.starts_with("http://") || url.starts_with("https://") {
65 return Self::from_http_url(url, host);
66 }
67
68 Err(invalid_url(url))
69 }
70
71 fn from_gs_url(rest: &str) -> StorageResult<Self> {
72 let mut parts = rest.splitn(2, '/');
73 let bucket = parts
74 .next()
75 .filter(|s| !s.is_empty())
76 .ok_or_else(|| invalid_url(rest))?;
77 let path = parts.next().unwrap_or_default();
78 Ok(Self::new(bucket, path))
79 }
80
81 fn from_http_url(url: &str, configured_host: &str) -> StorageResult<Self> {
82 let parsed = Url::parse(url).map_err(|_| invalid_url(url))?;
83 let host = parsed.host_str().ok_or_else(|| invalid_url(url))?;
84 let is_default_host = configured_host == DEFAULT_HOST;
85 let host_matches = host.eq_ignore_ascii_case(configured_host)
86 || (is_default_host
87 && matches!(host, "storage.googleapis.com" | "storage.cloud.google.com"));
88 if !host_matches {
89 return Err(invalid_url(url));
90 }
91
92 let mut segments = parsed
93 .path_segments()
94 .ok_or_else(|| invalid_url(url))?
95 .map(|segment| segment.to_string())
96 .collect::<Vec<_>>();
97
98 if segments.last().is_some_and(|s| s.is_empty()) {
100 segments.pop();
101 }
102
103 if segments.is_empty() {
104 return Err(invalid_url(url));
105 }
106
107 if host.eq_ignore_ascii_case(configured_host) {
108 if segments.len() < 4 || segments[1] != "b" || segments[3] != "o" {
110 return Err(invalid_url(url));
111 }
112 let bucket = &segments[2];
113 let path_segments = &segments[4..];
114 let decoded_path = decode_path_segments(path_segments)?;
115 return Ok(Self::new(bucket, decoded_path));
116 }
117
118 let bucket = segments.first().ok_or_else(|| invalid_url(url))?.clone();
120 let decoded_path = decode_path_segments(&segments[1..])?;
121 Ok(Self::new(bucket, decoded_path))
122 }
123}
124
125fn decode_path_segments(segments: &[String]) -> StorageResult<String> {
126 let decoded = segments
127 .iter()
128 .map(|segment| percent_decode_str(segment).decode_utf8_lossy())
129 .collect::<Vec<_>>()
130 .join("/");
131 Ok(decoded)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn parses_gs_url() {
140 let location = Location::from_url("gs://bucket/path/to/file", DEFAULT_HOST).unwrap();
141 assert_eq!(location.bucket(), "bucket");
142 assert_eq!(location.path(), "path/to/file");
143 }
144
145 #[test]
146 fn parses_default_bucket_spec() {
147 let location = Location::from_bucket_spec("gs://bucket", DEFAULT_HOST).unwrap();
148 assert_eq!(location.bucket(), "bucket");
149 assert!(location.is_root());
150 }
151
152 #[test]
153 fn rejects_bucket_spec_with_path() {
154 let err = Location::from_bucket_spec("gs://bucket/obj", DEFAULT_HOST).unwrap_err();
155 assert_eq!(err.code_str(), "storage/invalid-default-bucket");
156 }
157
158 #[test]
159 fn parses_firebase_storage_url() {
160 let url = format!(
161 "https://{}/v0/b/my-bucket/o/path%2Fto%2Fobject",
162 DEFAULT_HOST
163 );
164 let location = Location::from_url(&url, DEFAULT_HOST).unwrap();
165 assert_eq!(location.bucket(), "my-bucket");
166 assert_eq!(location.path(), "path/to/object");
167 }
168
169 #[test]
170 fn parses_cloud_storage_url() {
171 let url = "https://storage.googleapis.com/my-bucket/path/to/object";
172 let location = Location::from_url(url, DEFAULT_HOST).unwrap();
173 assert_eq!(location.bucket(), "my-bucket");
174 assert_eq!(location.path(), "path/to/object");
175 }
176}