Skip to main content

objstore_r/gcs/
mod.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Google Cloud Storage over the JSON API (feature `gcs`).
5//!
6//! - **Preconditions** map to `ifGenerationMatch` (`0` for create-only).
7//!   Status 412 becomes [`Error::Conflict`] or [`Error::PreconditionFailed`].
8//! - **Retries** with jittered exponential backoff on 408/429/5xx and on
9//!   transport errors. A conditional write whose first attempt succeeded but
10//!   whose response was lost comes back as 412 on retry; the store then reads
11//!   the object back and, if it holds exactly our bytes, reports success.
12//! - **Uploads** are single requests up to [`GcsConfig::resumable_threshold`]
13//!   and resumable sessions above it.
14//! - **Ranged reads** send `Range`, so a caller can fetch a header or one
15//!   section of a large snapshot without downloading all of it.
16//! - **Safety:** the endpoint must be `https://` (plain `http://` is accepted
17//!   only for a loopback emulator); redirects are refused; response bodies are
18//!   bounded by [`GcsConfig::max_object_bytes`]; error messages never include
19//!   tokens or request bodies.
20//! - **Content encoding** ([`ObjectStore::put_encoded`]): the compressed body
21//!   is uploaded with the `contentEncoding` insert parameter (documented for
22//!   `uploadType=media`, and accepted by the resumable session start).
23//!   Every read sends `Accept-Encoding: gzip`, so Cloud Storage serves the
24//!   stored bytes as they are instead of decompressing them server-side
25//!   (decompressive transcoding, which also ignores `Range`). A body served
26//!   with `Content-Encoding` is checked against the object's CRC-32C from
27//!   `x-goog-hash` — which covers the stored, compressed bytes — *before* it
28//!   is decoded, then inflated once under [`Gcs::with_max_inflated`]'s bound.
29//!   A ranged read of an encoded object is refused from the response headers,
30//!   before any body is read.
31
32mod crc32c;
33mod wire;
34
35#[doc(hidden)]
36pub use wire::fuzz as fuzz_wire;
37
38use crate::encoding::{self, ContentEncoding, MAX_INFLATED_BYTES};
39use crate::key::{check_prefix, encode_component};
40use crate::net::{backoff, is_allowed_endpoint, is_retriable_status, transport_error};
41use crate::token::TokenProvider;
42use crate::{Bytes, Cond, Error, Key, Meta, Object, ObjectStore, Result, Stat};
43use std::future::Future;
44use std::ops::Range;
45use std::time::Duration;
46
47/// One listing page: its items and the next page token.
48type Page<M> = (Vec<M>, Option<String>);
49
50/// Settings.
51#[derive(Debug, Clone)]
52pub struct GcsConfig {
53    /// API endpoint (`https://storage.googleapis.com`, or an emulator).
54    pub endpoint: String,
55    /// Per-request timeout.
56    pub timeout: Duration,
57    /// Retries after the first attempt for transient failures.
58    pub max_retries: u32,
59    /// Largest body accepted from a read.
60    pub max_object_bytes: u64,
61    /// Bodies at or above this size use a resumable upload session.
62    pub resumable_threshold: usize,
63}
64
65impl Default for GcsConfig {
66    fn default() -> Self {
67        Self {
68            endpoint: "https://storage.googleapis.com".into(),
69            timeout: Duration::from_secs(60),
70            max_retries: 5,
71            max_object_bytes: 4 << 30,
72            resumable_threshold: 8 << 20,
73        }
74    }
75}
76
77/// A bucket as an [`ObjectStore`].
78#[derive(Debug)]
79pub struct Gcs<T: TokenProvider> {
80    http: reqwest::Client,
81    tokens: T,
82    cfg: GcsConfig,
83    /// `{endpoint}/storage/v1/b/{bucket}`
84    api: String,
85    /// `{endpoint}/upload/storage/v1/b/{bucket}`
86    upload: String,
87    /// Bound on a decoded body.
88    max_inflated: u64,
89}
90
91impl<T: TokenProvider> Gcs<T> {
92    /// A store for `bucket`, authenticated by `tokens`.
93    pub fn new(bucket: &str, tokens: T, cfg: GcsConfig) -> Result<Self> {
94        if !is_valid_bucket(bucket) {
95            return Err(Error::InvalidKey { reason: "invalid bucket name" });
96        }
97        let endpoint = cfg.endpoint.trim_end_matches('/').to_owned();
98        if !is_allowed_endpoint(&endpoint) {
99            return Err(Error::protocol(
100                "the endpoint must be https:// (http:// only for a loopback emulator)",
101            ));
102        }
103        let http = reqwest::Client::builder()
104            .timeout(cfg.timeout)
105            .redirect(reqwest::redirect::Policy::none())
106            .pool_idle_timeout(Duration::from_secs(90))
107            .user_agent(concat!("objstore-r/", env!("CARGO_PKG_VERSION")))
108            .build()
109            .map_err(|e| Error::unavailable(format!("http client: {e}"), false))?;
110        Ok(Self {
111            http,
112            tokens,
113            api: format!("{endpoint}/storage/v1/b/{bucket}"),
114            upload: format!("{endpoint}/upload/storage/v1/b/{bucket}"),
115            cfg,
116            max_inflated: MAX_INFLATED_BYTES,
117        })
118    }
119
120    /// Refuse to decode an encoded object past `limit` bytes
121    /// ([`Error::TooLarge`]); the default is [`MAX_INFLATED_BYTES`]. The
122    /// stored bytes stay bounded by [`GcsConfig::max_object_bytes`].
123    #[must_use]
124    pub fn with_max_inflated(mut self, limit: u64) -> Self {
125        self.max_inflated = limit;
126        self
127    }
128
129    /// The token provider.
130    pub fn tokens(&self) -> &T {
131        &self.tokens
132    }
133
134    fn object_url(&self, key: &str) -> String {
135        let mut u = String::with_capacity(self.api.len() + key.len() * 3 + 4);
136        u.push_str(&self.api);
137        u.push_str("/o/");
138        encode_component(key, &mut u);
139        u
140    }
141
142    async fn authed(&self, rb: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
143        let t = self.tokens.token().await?;
144        let mut rb = rb;
145        if !t.secret().is_empty() {
146            rb = rb.bearer_auth(t.secret());
147        }
148        if let Some(q) = self.tokens.quota_project() {
149            rb = rb.header("x-goog-user-project", q);
150        }
151        Ok(rb)
152    }
153
154    /// Send with retries. `build` makes a fresh request per attempt.
155    async fn send(&self, build: impl Fn() -> reqwest::RequestBuilder) -> Result<reqwest::Response> {
156        let mut attempt = 0;
157        loop {
158            let rb = self.authed(build()).await?;
159            let outcome = rb.send().await;
160            let retry = match &outcome {
161                Ok(r) => is_retriable_status(r.status().as_u16()),
162                Err(e) => e.is_timeout() || e.is_connect() || e.is_request(),
163            };
164            if !retry || attempt >= self.cfg.max_retries {
165                return outcome.map_err(|e| transport_error(&e));
166            }
167            attempt += 1;
168            tokio::time::sleep(backoff(attempt)).await;
169        }
170    }
171
172    async fn body(&self, resp: reqwest::Response) -> Result<Bytes> {
173        if resp.content_length().is_some_and(|n| n > self.cfg.max_object_bytes) {
174            return Err(Error::TooLarge { limit: self.cfg.max_object_bytes });
175        }
176        let mut resp = resp;
177        let mut buf = bytes::BytesMut::new();
178        while let Some(chunk) = resp.chunk().await.map_err(|e| transport_error(&e))? {
179            if (buf.len() + chunk.len()) as u64 > self.cfg.max_object_bytes {
180                return Err(Error::TooLarge { limit: self.cfg.max_object_bytes });
181            }
182            buf.extend_from_slice(&chunk);
183        }
184        Ok(buf.freeze())
185    }
186
187    async fn head_inner(&self, key: &str) -> Result<Option<Meta>> {
188        let url = format!("{}?fields=name,size,generation", self.object_url(key));
189        let resp = self.send(|| self.http.get(&url)).await?;
190        match resp.status().as_u16() {
191            404 => Ok(None),
192            200 => {
193                let body = self.body(resp).await?;
194                wire::parse_meta(&body).map(Some)
195            }
196            _ => Err(status_error(resp).await),
197        }
198    }
199
200    async fn stat_inner(&self, key: &str) -> Result<Option<Stat>> {
201        let url = format!("{}?fields=name,size,generation,contentEncoding", self.object_url(key));
202        let resp = self.send(|| self.http.get(&url)).await?;
203        match resp.status().as_u16() {
204            404 => Ok(None),
205            200 => {
206                let body = self.body(resp).await?;
207                wire::parse_stat(&body).map(Some)
208            }
209            _ => Err(status_error(resp).await),
210        }
211    }
212
213    async fn get_inner(&self, key: &str) -> Result<Option<Object>> {
214        let url = format!("{}?alt=media", self.object_url(key));
215        let resp = self.send(|| stored_bytes(self.http.get(&url))).await?;
216        match resp.status().as_u16() {
217            404 => Ok(None),
218            200 => {
219                let header_gen = generation_header(&resp);
220                let served = served_encoding(&resp)?;
221                let sum = served.and_then(|_| stored_crc32c(&resp));
222                let mut bytes = self.body(resp).await?;
223                if let Some(e) = served {
224                    // The checksum covers the stored bytes, which is what an
225                    // encoded body is served as: verify, then decode once.
226                    if sum.is_some_and(|want| crc32c::crc32c(&bytes) != want) {
227                        return Err(Error::corrupt(format!(
228                            "{key}: stored bytes fail their CRC-32C"
229                        )));
230                    }
231                    bytes = encoding::decode(e, &bytes, self.max_inflated)?;
232                }
233                let generation = match header_gen {
234                    Some(g) => g,
235                    // Emulators may omit the header: ask for the metadata.
236                    None => self.head_inner(key).await?.map_or(1, |m| m.generation),
237                };
238                Ok(Some(Object { bytes, generation }))
239            }
240            _ => Err(status_error(resp).await),
241        }
242    }
243
244    async fn range_inner(&self, key: &str, range: Range<u64>) -> Result<Bytes> {
245        if range.start >= range.end {
246            return match self.stat_inner(key).await? {
247                Some(Stat { content_encoding: Some(e), .. }) => {
248                    Err(Error::EncodedRange { encoding: e.as_str().into() })
249                }
250                Some(_) => Ok(Bytes::new()),
251                None => Err(Error::NotFound),
252            };
253        }
254        let url = format!("{}?alt=media", self.object_url(key));
255        let header = format!("bytes={}-{}", range.start, range.end - 1);
256        // `Accept-Encoding: gzip` keeps the server from decompressing an
257        // encoded object (and ignoring the range) only to have it refused.
258        let resp = self.send(|| stored_bytes(self.http.get(&url).header("range", &header))).await?;
259        if matches!(resp.status().as_u16(), 200 | 206 | 416) {
260            if let Some(token) = stored_encoding(&resp) {
261                return Err(Error::EncodedRange { encoding: token });
262            }
263        }
264        match resp.status().as_u16() {
265            404 => Err(Error::NotFound),
266            // A range past the end names no encoding in its headers: only
267            // an encoded object costs the metadata read that settles it.
268            416 => match self.stat_inner(key).await? {
269                Some(Stat { content_encoding: Some(e), .. }) => {
270                    Err(Error::EncodedRange { encoding: e.as_str().into() })
271                }
272                _ => Ok(Bytes::new()),
273            },
274            206 => self.body(resp).await,
275            // A server may ignore Range and send everything: slice it here.
276            200 => {
277                let all = self.body(resp).await?;
278                Ok(all.slice(crate::clip(&range, all.len() as u64)))
279            }
280            _ => Err(status_error(resp).await),
281        }
282    }
283
284    /// Upload `stored` (the bytes as kept: `body` itself, or `body` encoded
285    /// with `encoding`).
286    async fn put_inner(
287        &self,
288        key: &str,
289        body: Bytes,
290        stored: Bytes,
291        cond: Cond,
292        encoding: Option<ContentEncoding>,
293    ) -> Result<u64> {
294        let mut query = String::from("name=");
295        encode_component(key, &mut query);
296        if let Some(e) = encoding {
297            query.push_str("&contentEncoding=");
298            query.push_str(e.as_str());
299        }
300        match cond {
301            Cond::None => {}
302            Cond::CreateOnly => query.push_str("&ifGenerationMatch=0"),
303            Cond::IfGeneration(g) => {
304                use std::fmt::Write;
305                let _ = write!(query, "&ifGenerationMatch={g}");
306            }
307        }
308        let (status, resp) = if stored.len() >= self.cfg.resumable_threshold {
309            self.put_resumable(&query, &stored).await?
310        } else {
311            let url = format!("{}/o?uploadType=media&{query}", self.upload);
312            let resp = self
313                .send(|| {
314                    self.http
315                        .post(&url)
316                        .header("content-type", "application/octet-stream")
317                        .body(stored.clone())
318                })
319                .await?;
320            (resp.status().as_u16(), resp)
321        };
322        match status {
323            200 | 201 => {
324                let b = self.body(resp).await?;
325                wire::parse_meta(&b).map(|m| m.generation)
326            }
327            412 if cond != Cond::None => {
328                // Either a real conflict, or our earlier attempt landed and its
329                // response was lost. Our bytes being there settles it (an
330                // encoded object reads back decoded, so compare the body).
331                if let Some(o) = self.get_inner(key).await? {
332                    if o.bytes == body
333                        && !matches!(cond, Cond::IfGeneration(g) if g == o.generation)
334                    {
335                        return Ok(o.generation);
336                    }
337                }
338                Err(if cond == Cond::CreateOnly {
339                    Error::Conflict
340                } else {
341                    Error::PreconditionFailed
342                })
343            }
344            _ => Err(status_error(resp).await),
345        }
346    }
347
348    async fn put_resumable(&self, query: &str, body: &Bytes) -> Result<(u16, reqwest::Response)> {
349        let init = format!("{}/o?uploadType=resumable&{query}", self.upload);
350        let resp = self
351            .send(|| {
352                self.http
353                    .post(&init)
354                    .header("content-type", "application/json")
355                    .header("x-upload-content-type", "application/octet-stream")
356                    .header("x-upload-content-length", body.len().to_string())
357                    .body("{}")
358            })
359            .await?;
360        let status = resp.status().as_u16();
361        if status != 200 && status != 201 {
362            return Ok((status, resp));
363        }
364        let session = resp
365            .headers()
366            .get("location")
367            .and_then(|v| v.to_str().ok())
368            .map(str::to_owned)
369            .ok_or_else(|| Error::protocol("resumable upload without a session URL"))?;
370        if !(session.starts_with(&self.upload) || session.starts_with("https://")) {
371            return Err(Error::protocol("resumable session URL on an unexpected host"));
372        }
373        let resp = self.send(|| self.http.put(&session).body(body.clone())).await?;
374        Ok((resp.status().as_u16(), resp))
375    }
376
377    async fn list_inner(&self, prefix: &str) -> Result<Vec<Meta>> {
378        self.list_pages(prefix, "items(name,size,generation)", wire::parse_list, |m| &m.key).await
379    }
380
381    async fn list_stat_inner(&self, prefix: &str) -> Result<Vec<Stat>> {
382        self.list_pages(
383            prefix,
384            "items(name,size,generation,contentEncoding)",
385            wire::parse_list_stat,
386            |s| &s.meta.key,
387        )
388        .await
389    }
390
391    /// Every page of a listing of `items` fields, parsed by `parse`.
392    async fn list_pages<M: Ord>(
393        &self,
394        prefix: &str,
395        items: &str,
396        parse: fn(&[u8]) -> Result<Page<M>>,
397        key_of: fn(&M) -> &String,
398    ) -> Result<Vec<M>> {
399        let mut out = Vec::new();
400        let mut page: Option<String> = None;
401        loop {
402            let mut url = format!("{}/o?fields={items},nextPageToken&maxResults=1000", self.api);
403            if !prefix.is_empty() {
404                url.push_str("&prefix=");
405                encode_component(prefix, &mut url);
406            }
407            if let Some(p) = &page {
408                url.push_str("&pageToken=");
409                encode_component(p, &mut url);
410            }
411            let resp = self.send(|| self.http.get(&url)).await?;
412            if resp.status().as_u16() != 200 {
413                return Err(status_error(resp).await);
414            }
415            let body = self.body(resp).await?;
416            let (items, next) = parse(&body)?;
417            // Objects written by other tools may not fit the key grammar: skip them.
418            out.extend(items.into_iter().filter(|m| {
419                let k = key_of(m);
420                Key::parse(k).is_ok() && k.starts_with(prefix)
421            }));
422            match next {
423                Some(n) if !n.is_empty() => page = Some(n),
424                _ => break,
425            }
426        }
427        out.sort();
428        Ok(out)
429    }
430
431    async fn delete_inner(&self, key: &str) -> Result<()> {
432        let url = self.object_url(key);
433        let resp = self.send(|| self.http.delete(&url)).await?;
434        match resp.status().as_u16() {
435            200 | 204 | 404 => Ok(()),
436            _ => Err(status_error(resp).await),
437        }
438    }
439}
440
441impl<T: TokenProvider> ObjectStore for Gcs<T> {
442    type Get<'a>
443        = impl Future<Output = Result<Option<Object>>> + 'a
444    where
445        Self: 'a;
446    type GetRange<'a>
447        = impl Future<Output = Result<Bytes>> + 'a
448    where
449        Self: 'a;
450    type Head<'a>
451        = impl Future<Output = Result<Option<Meta>>> + 'a
452    where
453        Self: 'a;
454    type Put<'a>
455        = impl Future<Output = Result<u64>> + 'a
456    where
457        Self: 'a;
458    type List<'a>
459        = impl Future<Output = Result<Vec<Meta>>> + 'a
460    where
461        Self: 'a;
462    type Delete<'a>
463        = impl Future<Output = Result<()>> + 'a
464    where
465        Self: 'a;
466
467    fn get<'a>(&'a self, key: &'a str) -> Self::Get<'a> {
468        async move { self.get_inner(Key::parse(key)?.as_str()).await }
469    }
470
471    fn get_range<'a>(&'a self, key: &'a str, range: Range<u64>) -> Self::GetRange<'a> {
472        async move { self.range_inner(Key::parse(key)?.as_str(), range).await }
473    }
474
475    fn head<'a>(&'a self, key: &'a str) -> Self::Head<'a> {
476        async move { self.head_inner(Key::parse(key)?.as_str()).await }
477    }
478
479    fn put<'a>(&'a self, key: &'a str, body: Bytes, cond: Cond) -> Self::Put<'a> {
480        async move { self.put_inner(Key::parse(key)?.as_str(), body.clone(), body, cond, None).await }
481    }
482
483    fn list<'a>(&'a self, prefix: &'a str) -> Self::List<'a> {
484        async move {
485            check_prefix(prefix)?;
486            self.list_inner(prefix).await
487        }
488    }
489
490    fn delete<'a>(&'a self, key: &'a str) -> Self::Delete<'a> {
491        async move { self.delete_inner(Key::parse(key)?.as_str()).await }
492    }
493
494    async fn put_encoded<'a>(
495        &'a self,
496        key: &'a str,
497        body: Bytes,
498        cond: Cond,
499        encoding: ContentEncoding,
500    ) -> Result<u64> {
501        let key = Key::parse(key)?.as_str();
502        let stored = encoding::encode(encoding, &body)?;
503        self.put_inner(key, body, stored, cond, Some(encoding)).await
504    }
505
506    async fn stat<'a>(&'a self, key: &'a str) -> Result<Option<Stat>> {
507        self.stat_inner(Key::parse(key)?.as_str()).await
508    }
509
510    async fn list_stat<'a>(&'a self, prefix: &'a str) -> Result<Vec<Stat>> {
511        check_prefix(prefix)?;
512        self.list_stat_inner(prefix).await
513    }
514}
515
516/// GCS bucket names: 3–222 characters of `[a-z0-9._-]`, starting and ending
517/// with a letter or digit.
518pub(crate) fn is_valid_bucket(b: &str) -> bool {
519    (3..=222).contains(&b.len())
520        && b.bytes().all(|c| {
521            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-')
522        })
523        && b.bytes().next().is_some_and(|c| c.is_ascii_alphanumeric())
524        && b.bytes().last().is_some_and(|c| c.is_ascii_alphanumeric())
525}
526
527fn generation_header(resp: &reqwest::Response) -> Option<u64> {
528    resp.headers()
529        .get("x-goog-generation")
530        .and_then(|v| v.to_str().ok())
531        .and_then(|s| s.parse().ok())
532        .filter(|g| *g != 0)
533}
534
535/// Ask for an object's stored bytes as they are (`Accept-Encoding: gzip`),
536/// so Cloud Storage never decompresses server-side. Only a build that can
537/// decode them asks: without feature `gzip`, a gzip object written by another
538/// tool keeps arriving decoded by the server, exactly as before.
539fn stored_bytes(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
540    if cfg!(feature = "gzip") {
541        req.header("accept-encoding", "gzip")
542    } else {
543        req
544    }
545}
546
547/// The encoding a body was *served* with (`Content-Encoding`); `None` for
548/// bytes as written, including bytes the server already decoded.
549fn served_encoding(resp: &reqwest::Response) -> Result<Option<ContentEncoding>> {
550    match resp.headers().get("content-encoding") {
551        None => Ok(None),
552        Some(v) => ContentEncoding::parse(
553            v.to_str().map_err(|_| Error::protocol("unreadable content-encoding"))?,
554        ),
555    }
556}
557
558/// The encoding an object is *stored* with, from either header (a server may
559/// send only one): `None` for identity.
560fn stored_encoding(resp: &reqwest::Response) -> Option<String> {
561    ["x-goog-stored-content-encoding", "content-encoding"].iter().find_map(|h| {
562        let v = resp.headers().get(*h)?.to_str().unwrap_or("unreadable").trim();
563        (!v.is_empty() && !v.eq_ignore_ascii_case("identity")).then(|| v.chars().take(64).collect())
564    })
565}
566
567/// The stored bytes' CRC-32C, when the body is served as stored.
568fn stored_crc32c(resp: &reqwest::Response) -> Option<u32> {
569    let h = resp.headers();
570    let served = h.get("content-encoding").and_then(|v| v.to_str().ok());
571    // Absent: assume as stored (Cloud Storage always sends it; a server that
572    // does not cannot be transcoding either).
573    let stored = h.get("x-goog-stored-content-encoding").and_then(|v| v.to_str().ok());
574    if stored.is_some_and(|s| Some(s) != served) {
575        return None;
576    }
577    crc32c::from_goog_hash(h.get_all("x-goog-hash").iter().filter_map(|v| v.to_str().ok()))
578}
579
580async fn status_error(resp: reqwest::Response) -> Error {
581    let status = resp.status().as_u16();
582    let body = resp.bytes().await.unwrap_or_default();
583    crate::net::status_error(status, &body)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn only_a_build_that_decodes_asks_for_stored_bytes() {
592        // Without the codec, asking would turn a gzip object another tool
593        // wrote (served decoded by default) into an unreadable one.
594        let req = stored_bytes(reqwest::Client::new().get("http://127.0.0.1/o")).build().unwrap();
595        let asked = req.headers().get("accept-encoding").and_then(|v| v.to_str().ok());
596        assert_eq!(asked, cfg!(feature = "gzip").then_some("gzip"));
597    }
598
599    #[test]
600    fn bucket_names_and_endpoints() {
601        assert!(is_valid_bucket("my-project-sayasaya-state") && is_valid_bucket("a.b_c"));
602        for bad in ["ab", "-ab", "ab-", "AB", "a/b", &"a".repeat(223)] {
603            assert!(!is_valid_bucket(bad), "{bad}");
604        }
605        let cfg = |e: &str| GcsConfig { endpoint: e.into(), ..GcsConfig::default() };
606        let t = crate::token::Static::none;
607        assert!(
608            Gcs::new("bkt", t(), cfg("http://example.com")).is_err(),
609            "plain http to a remote host is refused"
610        );
611        assert!(Gcs::new("bkt", t(), cfg("http://127.0.0.1:4443")).is_ok());
612        assert!(Gcs::new("bkt", t(), cfg("https://storage.googleapis.com/")).is_ok());
613    }
614}