firebase_rs_sdk/storage/
location.rs

1use 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        // Remove empty trailing segment introduced by trailing slash.
99        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            // Expect /{version}/b/{bucket}/o/{path...}
109            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        // Cloud storage host variants: /{bucket}/{path...}
119        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}