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 below [`GcsConfig::resumable_threshold`]
13//!   and chunked resumable sessions at or above it: each
14//!   [`GcsConfig::upload_chunk`] is one `PUT` with `Content-Range`, and a
15//!   failed chunk resumes from the offset the server reports as committed,
16//!   so only the remainder is sent again.
17//! - **Downloads** resume: a whole-object read that drops mid-body asks for
18//!   the rest with `Range`, pinned to the generation it started on, and
19//!   verifies the object's CRC-32C over the assembled bytes.
20//! - **Timeouts** bound stalls, not sizes (see [`GcsConfig`]): a transfer may
21//!   take as long as it needs while bytes keep moving, and fails within
22//!   [`GcsConfig::idle_timeout`] once they stop.
23//! - **Ranged reads** send `Range`, so a caller can fetch a header or one
24//!   section of a large snapshot without downloading all of it.
25//! - **Safety:** the endpoint must be `https://` (plain `http://` is accepted
26//!   only for a loopback emulator); redirects are refused; response bodies are
27//!   bounded by [`GcsConfig::max_object_bytes`]; error messages never include
28//!   tokens or request bodies.
29//! - **Content encoding** ([`ObjectStore::put_encoded`]): the compressed body
30//!   is uploaded with the `contentEncoding` insert parameter (documented for
31//!   `uploadType=media`, and accepted by the resumable session start).
32//!   Every read sends `Accept-Encoding: gzip`, so Cloud Storage serves the
33//!   stored bytes as they are instead of decompressing them server-side
34//!   (decompressive transcoding, which also ignores `Range`). A body served
35//!   with `Content-Encoding` is checked against the object's CRC-32C from
36//!   `x-goog-hash` — which covers the stored, compressed bytes — *before* it
37//!   is decoded, then inflated once under [`Gcs::with_max_inflated`]'s bound.
38//!   A ranged read of an encoded object is refused from the response headers,
39//!   before any body is read.
40
41mod crc32c;
42mod ranges;
43mod wire;
44
45#[doc(hidden)]
46pub use wire::fuzz as fuzz_wire;
47
48use crate::encoding::{self, ContentEncoding, MAX_INFLATED_BYTES};
49use crate::key::{check_prefix, encode_component};
50use crate::net::{backoff, is_allowed_endpoint, is_retriable_status, transport_error};
51use crate::token::TokenProvider;
52use crate::{Bytes, Cond, Error, Key, Meta, Object, ObjectStore, Result, Stat};
53use std::future::Future;
54use std::ops::Range;
55use std::time::Duration;
56
57/// One listing page: its items and the next page token.
58type Page<M> = (Vec<M>, Option<String>);
59
60/// Granularity of a resumable upload chunk (Cloud Storage's rule).
61const CHUNK_UNIT: usize = 256 << 10;
62
63/// Settings.
64///
65/// No timeout covers a whole transfer: a 300 MiB object over a slow link
66/// takes as long as it takes. What is bounded is time without progress — a
67/// response that does not start, a body that stops arriving, an upload chunk
68/// that does not finish at a floor rate — so a dead connection fails in
69/// about [`idle_timeout`](Self::idle_timeout) whatever the object's size.
70#[derive(Debug, Clone)]
71pub struct GcsConfig {
72    /// API endpoint (`https://storage.googleapis.com`, or an emulator).
73    pub endpoint: String,
74    /// Connection establishment (TCP, TLS and any proxy `CONNECT`).
75    pub connect_timeout: Duration,
76    /// A whole small request: metadata, listing pages, deletes, starting a
77    /// resumable session and asking it for its committed offset. Also the
78    /// wait for a download's response headers, and the fixed part of an
79    /// upload's bound (see [`min_throughput`](Self::min_throughput)).
80    pub request_timeout: Duration,
81    /// The longest a download may receive no bytes before the attempt fails
82    /// (and resumes from where it stopped).
83    pub idle_timeout: Duration,
84    /// The slowest upload rate tolerated, in bytes per second: one upload
85    /// request of `n` bytes (a chunk, or a whole small object) fails after
86    /// `request_timeout + n / min_throughput`. Must not be zero.
87    pub min_throughput: u64,
88    /// Retries after the first attempt for transient failures; for a
89    /// resumable transfer, consecutive attempts that make no progress.
90    pub max_retries: u32,
91    /// Largest body accepted from a read, across every resumed attempt.
92    pub max_object_bytes: u64,
93    /// Bodies at or above this size use a resumable upload session.
94    pub resumable_threshold: usize,
95    /// Bytes per `PUT` of a resumable upload: a non-zero multiple of 256 KiB.
96    /// Smaller chunks lose less to a failure and cost a round trip each.
97    pub upload_chunk: usize,
98}
99
100impl Default for GcsConfig {
101    fn default() -> Self {
102        Self {
103            endpoint: "https://storage.googleapis.com".into(),
104            connect_timeout: Duration::from_secs(10),
105            request_timeout: Duration::from_secs(30),
106            idle_timeout: Duration::from_secs(60),
107            min_throughput: 64 << 10,
108            max_retries: 5,
109            max_object_bytes: 4 << 30,
110            resumable_threshold: 8 << 20,
111            upload_chunk: 8 << 20,
112        }
113    }
114}
115
116impl GcsConfig {
117    fn check(&self) -> Result<()> {
118        let bad = |what| Err(Error::protocol(format!("invalid configuration: {what}")));
119        if [self.connect_timeout, self.request_timeout, self.idle_timeout].contains(&Duration::ZERO)
120        {
121            return bad("timeouts must be positive");
122        }
123        if self.min_throughput == 0 {
124            return bad("min_throughput must be positive");
125        }
126        if self.upload_chunk == 0 || !self.upload_chunk.is_multiple_of(CHUNK_UNIT) {
127            return bad("upload_chunk must be a positive multiple of 256 KiB");
128        }
129        Ok(())
130    }
131
132    /// The bound on one upload request carrying `len` bytes.
133    fn upload_bound(&self, len: usize) -> Duration {
134        let ms = (len as u128 * 1000) / u128::from(self.min_throughput);
135        self.request_timeout
136            .saturating_add(Duration::from_millis(u64::try_from(ms).unwrap_or(u64::MAX)))
137    }
138}
139
140/// How long one request may take.
141#[derive(Debug, Clone, Copy)]
142enum Bound {
143    /// The whole exchange, body included (small requests, upload requests).
144    Whole(Duration),
145    /// Until the response headers; the body is then read under the idle
146    /// timeout, chunk by chunk (downloads).
147    Headers(Duration),
148}
149
150/// How a resumable session ended.
151enum Session {
152    /// The final response: the object, or a status for the caller to judge.
153    Done(u16, reqwest::Response),
154    /// 404/410: the session expired.
155    Gone,
156}
157
158/// A bucket as an [`ObjectStore`].
159#[derive(Debug)]
160pub struct Gcs<T: TokenProvider> {
161    http: reqwest::Client,
162    tokens: T,
163    cfg: GcsConfig,
164    /// `{endpoint}/storage/v1/b/{bucket}`
165    api: String,
166    /// `{endpoint}/upload/storage/v1/b/{bucket}`
167    upload: String,
168    /// Bound on a decoded body.
169    max_inflated: u64,
170}
171
172impl<T: TokenProvider> Gcs<T> {
173    /// A store for `bucket`, authenticated by `tokens`.
174    pub fn new(bucket: &str, tokens: T, cfg: GcsConfig) -> Result<Self> {
175        if !is_valid_bucket(bucket) {
176            return Err(Error::InvalidKey { reason: "invalid bucket name" });
177        }
178        let endpoint = cfg.endpoint.trim_end_matches('/').to_owned();
179        if !is_allowed_endpoint(&endpoint) {
180            return Err(Error::protocol(
181                "the endpoint must be https:// (http:// only for a loopback emulator)",
182            ));
183        }
184        cfg.check()?;
185        // No client-wide timeout: each request carries its own bound (see
186        // `Bound`). reqwest's `read_timeout` is not used either: its timer
187        // runs from the send to the response headers without being reset by
188        // request-body progress, so on an upload it is a total timeout again.
189        // The system proxy (`HTTPS_PROXY`, `NO_PROXY`) applies as reqwest's
190        // default.
191        let http = reqwest::Client::builder()
192            .connect_timeout(cfg.connect_timeout)
193            .redirect(reqwest::redirect::Policy::none())
194            .pool_idle_timeout(Duration::from_secs(90))
195            .user_agent(concat!("objstore-r/", env!("CARGO_PKG_VERSION")))
196            .build()
197            .map_err(|e| Error::unavailable(format!("http client: {e}"), false))?;
198        Ok(Self {
199            http,
200            tokens,
201            api: format!("{endpoint}/storage/v1/b/{bucket}"),
202            upload: format!("{endpoint}/upload/storage/v1/b/{bucket}"),
203            cfg,
204            max_inflated: MAX_INFLATED_BYTES,
205        })
206    }
207
208    /// Refuse to decode an encoded object past `limit` bytes
209    /// ([`Error::TooLarge`]); the default is [`MAX_INFLATED_BYTES`]. The
210    /// stored bytes stay bounded by [`GcsConfig::max_object_bytes`].
211    #[must_use]
212    pub fn with_max_inflated(mut self, limit: u64) -> Self {
213        self.max_inflated = limit;
214        self
215    }
216
217    /// The token provider.
218    pub fn tokens(&self) -> &T {
219        &self.tokens
220    }
221
222    fn object_url(&self, key: &str) -> String {
223        let mut u = String::with_capacity(self.api.len() + key.len() * 3 + 4);
224        u.push_str(&self.api);
225        u.push_str("/o/");
226        encode_component(key, &mut u);
227        u
228    }
229
230    async fn authed(&self, rb: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
231        let t = self.tokens.token().await?;
232        let mut rb = rb;
233        if !t.secret().is_empty() {
234            rb = rb.bearer_auth(t.secret());
235        }
236        if let Some(q) = self.tokens.quota_project() {
237            rb = rb.header("x-goog-user-project", q);
238        }
239        Ok(rb)
240    }
241
242    /// A small request's bound.
243    fn small(&self) -> Bound {
244        Bound::Whole(self.cfg.request_timeout)
245    }
246
247    /// A download's bound: headers within `request_timeout`, then the body
248    /// under `idle_timeout`.
249    fn download(&self) -> Bound {
250        Bound::Headers(self.cfg.request_timeout)
251    }
252
253    /// One attempt of an authorised request under `bound`.
254    async fn dispatch(rb: reqwest::RequestBuilder, bound: Bound) -> Result<reqwest::Response> {
255        match bound {
256            Bound::Whole(d) => rb.timeout(d).send().await.map_err(|e| transport_error(&e)),
257            Bound::Headers(d) => match tokio::time::timeout(d, rb.send()).await {
258                Ok(r) => r.map_err(|e| transport_error(&e)),
259                Err(_) => Err(Error::unavailable("timed out", true)),
260            },
261        }
262    }
263
264    /// Send with retries. `build` makes a fresh request per attempt.
265    async fn send(
266        &self,
267        bound: Bound,
268        build: impl Fn() -> reqwest::RequestBuilder,
269    ) -> Result<reqwest::Response> {
270        let mut attempt = 0;
271        loop {
272            // A fresh token per attempt (the provider caches it and renews it
273            // before expiry); failing to get one is reported at once.
274            let rb = self.authed(build()).await?;
275            let outcome = Self::dispatch(rb, bound).await;
276            let retry = match &outcome {
277                Ok(r) => is_retriable_status(r.status().as_u16()),
278                Err(e) => e.is_retriable(),
279            };
280            if !retry || attempt >= self.cfg.max_retries {
281                return outcome;
282            }
283            attempt += 1;
284            tokio::time::sleep(backoff(attempt)).await;
285        }
286    }
287
288    /// Read `resp`'s body onto `buf`, each chunk within the idle timeout.
289    /// `Ok` once the body ends; a stall or a dropped connection is a
290    /// retriable [`Error::Unavailable`] with `buf` holding what arrived.
291    async fn read_into(
292        &self,
293        resp: &mut reqwest::Response,
294        buf: &mut bytes::BytesMut,
295    ) -> Result<()> {
296        let limit = self.cfg.max_object_bytes;
297        loop {
298            let chunk = match tokio::time::timeout(self.cfg.idle_timeout, resp.chunk()).await {
299                Err(_) => {
300                    return Err(Error::unavailable(
301                        "stalled: no data within the idle timeout",
302                        true,
303                    ))
304                }
305                Ok(Err(_)) => return Err(Error::unavailable("connection lost mid-body", true)),
306                Ok(Ok(None)) => return Ok(()),
307                Ok(Ok(Some(c))) => c,
308            };
309            if (buf.len() + chunk.len()) as u64 > limit {
310                return Err(Error::TooLarge { limit });
311            }
312            buf.extend_from_slice(&chunk);
313        }
314    }
315
316    /// A buffer for a body of `len` bytes (when known and allowed).
317    fn buffer(&self, len: Option<u64>) -> Result<bytes::BytesMut> {
318        let limit = self.cfg.max_object_bytes;
319        match len {
320            Some(n) if n > limit => Err(Error::TooLarge { limit }),
321            Some(n) => Ok(bytes::BytesMut::with_capacity(usize::try_from(n).unwrap_or(0))),
322            None => Ok(bytes::BytesMut::new()),
323        }
324    }
325
326    async fn body(&self, resp: reqwest::Response) -> Result<Bytes> {
327        let mut buf = self.buffer(resp.content_length())?;
328        let mut resp = resp;
329        self.read_into(&mut resp, &mut buf).await?;
330        Ok(buf.freeze())
331    }
332
333    async fn head_inner(&self, key: &str) -> Result<Option<Meta>> {
334        let url = format!("{}?fields=name,size,generation", self.object_url(key));
335        let resp = self.send(self.small(), || self.http.get(&url)).await?;
336        match resp.status().as_u16() {
337            404 => Ok(None),
338            200 => {
339                let body = self.body(resp).await?;
340                wire::parse_meta(&body).map(Some)
341            }
342            _ => Err(status_error(resp).await),
343        }
344    }
345
346    async fn stat_inner(&self, key: &str) -> Result<Option<Stat>> {
347        let url = format!("{}?fields=name,size,generation,contentEncoding", self.object_url(key));
348        let resp = self.send(self.small(), || self.http.get(&url)).await?;
349        match resp.status().as_u16() {
350            404 => Ok(None),
351            200 => {
352                let body = self.body(resp).await?;
353                wire::parse_stat(&body).map(Some)
354            }
355            _ => Err(status_error(resp).await),
356        }
357    }
358
359    async fn get_inner(&self, key: &str) -> Result<Option<Object>> {
360        let url = format!("{}?alt=media", self.object_url(key));
361        let resp = self.send(self.download(), || stored_bytes(self.http.get(&url))).await?;
362        match resp.status().as_u16() {
363            404 => Ok(None),
364            200 => {
365                let header_gen = generation_header(&resp);
366                let served = served_encoding(&resp)?;
367                let mut bytes = self.read_object(&url, resp, header_gen, served.is_some()).await?;
368                if let Some(e) = served {
369                    bytes = encoding::decode(e, &bytes, self.max_inflated)?;
370                }
371                let generation = match header_gen {
372                    Some(g) => g,
373                    // Emulators may omit the header: ask for the metadata.
374                    None => self.head_inner(key).await?.map_or(1, |m| m.generation),
375                };
376                Ok(Some(Object { bytes, generation }))
377            }
378            _ => Err(status_error(resp).await),
379        }
380    }
381
382    /// The whole body of a 200 media response, resuming when it breaks off,
383    /// verified against the stored CRC-32C (which covers the bytes as served
384    /// here: plain bytes, or an encoded body before it is decoded).
385    ///
386    /// A plain object of known length and generation resumes with
387    /// `Range: bytes=<received>-` pinned by `ifGenerationMatch`, so the parts
388    /// are always one generation. An encoded body cannot resume (a byte range
389    /// of a compressed stream is meaningless) and neither can a body of
390    /// unknown length or generation: those restart from byte 0. Either way
391    /// at most `max_retries` consecutive attempts may make no progress.
392    async fn read_object(
393        &self,
394        url: &str,
395        first: reqwest::Response,
396        generation: Option<u64>,
397        encoded: bool,
398    ) -> Result<Bytes> {
399        let total = first.content_length();
400        let ranged = !encoded && generation.is_some() && total.is_some();
401        let mut sum = stored_crc32c(&first);
402        let mut buf = self.buffer(total)?;
403        let mut resp = first;
404        // Progress is a body longer than any before it: a server that drops
405        // every attempt, or ignores `Range` and restarts, runs out of tries.
406        let mut best = 0;
407        let mut stalls = 0;
408        loop {
409            let failure = match self.read_into(&mut resp, &mut buf).await {
410                Ok(()) if total.is_none_or(|t| buf.len() as u64 == t) => break,
411                // Ended short of its Content-Length: a dropped body by
412                // another name.
413                Ok(()) => Error::unavailable("body ended early", true),
414                Err(e) if e.is_retriable() => e,
415                Err(e) => return Err(e),
416            };
417            stalls = if ranged && buf.len() > best { 0 } else { stalls + 1 };
418            best = best.max(buf.len());
419            if stalls > self.cfg.max_retries {
420                return Err(failure);
421            }
422            tokio::time::sleep(backoff(stalls.max(1))).await;
423            let from = if ranged { buf.len() as u64 } else { 0 };
424            resp = self.resume(url, generation, from, total).await?;
425            if resp.status().as_u16() == 200 {
426                // Whole again (a restart, or a server that ignored Range).
427                buf.clear();
428                sum = stored_crc32c(&resp);
429            }
430        }
431        if sum.is_some_and(|want| crc32c::crc32c(&buf) != want) {
432            return Err(Error::corrupt("stored bytes fail their CRC-32C"));
433        }
434        Ok(buf.freeze())
435    }
436
437    /// Ask again for an object read that broke off: from byte `from` (or
438    /// whole when `from == 0`), pinned to `generation` when known. Returns a
439    /// 200 or a 206 that continues exactly at `from`; a changed or deleted
440    /// object is [`Error::Changed`].
441    async fn resume(
442        &self,
443        url: &str,
444        generation: Option<u64>,
445        from: u64,
446        total: Option<u64>,
447    ) -> Result<reqwest::Response> {
448        let pinned = match generation {
449            Some(g) => format!("{url}&ifGenerationMatch={g}"),
450            None => url.to_owned(),
451        };
452        let range = format!("bytes={from}-");
453        let resp = self
454            .send(self.download(), || {
455                let rb = stored_bytes(self.http.get(&pinned));
456                if from == 0 {
457                    rb
458                } else {
459                    rb.header("range", &range)
460                }
461            })
462            .await?;
463        let status = resp.status().as_u16();
464        let same = generation.is_none_or(|g| generation_header(&resp).is_none_or(|h| h == g));
465        match status {
466            412 | 404 => Err(Error::Changed),
467            200 | 206 if !same => Err(Error::Changed),
468            200 => Ok(resp),
469            206 => {
470                let span = resp
471                    .headers()
472                    .get("content-range")
473                    .and_then(|v| v.to_str().ok())
474                    .and_then(ranges::content_range);
475                match span {
476                    Some((first, _, size)) if first == from && total.is_none_or(|t| t == size) => {
477                        Ok(resp)
478                    }
479                    _ => Err(Error::protocol("a resumed read answered with another range")),
480                }
481            }
482            _ => Err(status_error(resp).await),
483        }
484    }
485
486    async fn range_inner(&self, key: &str, range: Range<u64>) -> Result<Bytes> {
487        if range.start >= range.end {
488            return match self.stat_inner(key).await? {
489                Some(Stat { content_encoding: Some(e), .. }) => {
490                    Err(Error::EncodedRange { encoding: e.as_str().into() })
491                }
492                Some(_) => Ok(Bytes::new()),
493                None => Err(Error::NotFound),
494            };
495        }
496        let url = format!("{}?alt=media", self.object_url(key));
497        let header = format!("bytes={}-{}", range.start, range.end - 1);
498        // `Accept-Encoding: gzip` keeps the server from decompressing an
499        // encoded object (and ignoring the range) only to have it refused.
500        let resp = self
501            .send(self.download(), || stored_bytes(self.http.get(&url).header("range", &header)))
502            .await?;
503        if matches!(resp.status().as_u16(), 200 | 206 | 416) {
504            if let Some(token) = stored_encoding(&resp) {
505                return Err(Error::EncodedRange { encoding: token });
506            }
507        }
508        match resp.status().as_u16() {
509            404 => Err(Error::NotFound),
510            // A range past the end names no encoding in its headers: only
511            // an encoded object costs the metadata read that settles it.
512            416 => match self.stat_inner(key).await? {
513                Some(Stat { content_encoding: Some(e), .. }) => {
514                    Err(Error::EncodedRange { encoding: e.as_str().into() })
515                }
516                _ => Ok(Bytes::new()),
517            },
518            206 => self.body(resp).await,
519            // A server may ignore Range and send everything: slice it here.
520            200 => {
521                let all = self.body(resp).await?;
522                Ok(all.slice(crate::clip(&range, all.len() as u64)))
523            }
524            _ => Err(status_error(resp).await),
525        }
526    }
527
528    /// Upload `stored` (the bytes as kept: `body` itself, or `body` encoded
529    /// with `encoding`).
530    async fn put_inner(
531        &self,
532        key: &str,
533        body: Bytes,
534        stored: Bytes,
535        cond: Cond,
536        encoding: Option<ContentEncoding>,
537    ) -> Result<u64> {
538        let mut query = String::from("name=");
539        encode_component(key, &mut query);
540        if let Some(e) = encoding {
541            query.push_str("&contentEncoding=");
542            query.push_str(e.as_str());
543        }
544        match cond {
545            Cond::None => {}
546            Cond::CreateOnly => query.push_str("&ifGenerationMatch=0"),
547            Cond::IfGeneration(g) => {
548                use std::fmt::Write;
549                let _ = write!(query, "&ifGenerationMatch={g}");
550            }
551        }
552        let (status, resp) = if stored.len() >= self.cfg.resumable_threshold {
553            self.put_resumable(&query, &stored).await?
554        } else {
555            let url = format!("{}/o?uploadType=media&{query}", self.upload);
556            let bound = Bound::Whole(self.cfg.upload_bound(stored.len()));
557            let resp = self
558                .send(bound, || {
559                    self.http
560                        .post(&url)
561                        .header("content-type", "application/octet-stream")
562                        .body(stored.clone())
563                })
564                .await?;
565            (resp.status().as_u16(), resp)
566        };
567        match status {
568            200 | 201 => {
569                let b = self.body(resp).await?;
570                wire::parse_meta(&b).map(|m| m.generation)
571            }
572            412 if cond != Cond::None => {
573                // Either a real conflict, or our earlier attempt landed and its
574                // response was lost. Our bytes being there settles it (an
575                // encoded object reads back decoded, so compare the body).
576                if let Some(o) = self.get_inner(key).await? {
577                    if o.bytes == body
578                        && !matches!(cond, Cond::IfGeneration(g) if g == o.generation)
579                    {
580                        return Ok(o.generation);
581                    }
582                }
583                Err(if cond == Cond::CreateOnly {
584                    Error::Conflict
585                } else {
586                    Error::PreconditionFailed
587                })
588            }
589            _ => Err(status_error(resp).await),
590        }
591    }
592
593    /// A resumable upload: start a session (carrying the preconditions),
594    /// then send `body` in chunks. A session that expires mid-upload is
595    /// restarted once from byte 0. Returns the final status and response.
596    async fn put_resumable(&self, query: &str, body: &Bytes) -> Result<(u16, reqwest::Response)> {
597        let init = format!("{}/o?uploadType=resumable&{query}", self.upload);
598        let total = body.len().to_string();
599        let mut restarted = false;
600        loop {
601            let resp = self
602                .send(self.small(), || {
603                    self.http
604                        .post(&init)
605                        .header("content-type", "application/json")
606                        .header("x-upload-content-type", "application/octet-stream")
607                        .header("x-upload-content-length", &total)
608                        .body("{}")
609                })
610                .await?;
611            let status = resp.status().as_u16();
612            if status != 200 && status != 201 {
613                return Ok((status, resp));
614            }
615            let session = resp
616                .headers()
617                .get("location")
618                .and_then(|v| v.to_str().ok())
619                .ok_or_else(|| Error::protocol("resumable upload without a session URL"))?;
620            if !self.is_upload_url(session) {
621                return Err(Error::protocol("resumable session URL on an unexpected host"));
622            }
623            match self.upload_chunks(session, body).await? {
624                Session::Done(status, resp) => return Ok((status, resp)),
625                Session::Gone if !restarted => restarted = true,
626                Session::Gone => {
627                    return Err(Error::unavailable(
628                        "resumable upload session expired again after a restart",
629                        true,
630                    ))
631                }
632            }
633        }
634    }
635
636    /// A session URL must be on the upload endpoint's own origin: the chunks
637    /// carry our token and our bytes.
638    fn is_upload_url(&self, url: &str) -> bool {
639        // `upload` is `{scheme}://{authority}/upload/…`: the origin is
640        // everything before the first `/` after the scheme, and the URL must
641        // continue it with a `/` (not `.evil`, `@evil` or `:port`).
642        let after_scheme = self.upload.find("://").map_or(0, |i| i + 3);
643        let origin = self.upload[after_scheme..]
644            .find('/')
645            .map_or(self.upload.as_str(), |i| &self.upload[..after_scheme + i]);
646        url.strip_prefix(origin).is_some_and(|rest| rest.starts_with('/'))
647    }
648
649    /// Send `body` through `session` chunk by chunk.
650    ///
651    /// Each chunk is `PUT` with `Content-Range: bytes a-b/total`; a 308
652    /// reports the committed length in `Range`. After a transient failure
653    /// (timeout, lost connection, 408/429/5xx) the session is asked for its
654    /// committed length (`Content-Range: bytes */total`, empty body) and only
655    /// the remainder is sent again. At most `max_retries` consecutive
656    /// attempts may make no progress. The body is sliced, never copied.
657    async fn upload_chunks(&self, session: &str, body: &Bytes) -> Result<Session> {
658        let total = body.len() as u64;
659        // Highest committed length the server has confirmed: it never shrinks.
660        let mut acked = 0u64;
661        let mut stalls = 0u32;
662        let mut failed = false;
663        loop {
664            let resp = if failed {
665                // Where did it get to?
666                let rb = self
667                    .http
668                    .put(session)
669                    .header("content-range", format!("bytes */{total}"))
670                    .body(Bytes::new());
671                Self::dispatch(self.authed(rb).await?, self.small()).await
672            } else {
673                let end = acked.saturating_add(self.cfg.upload_chunk as u64).min(total);
674                let piece = body.slice(span(acked, end));
675                let range = if piece.is_empty() {
676                    format!("bytes */{total}")
677                } else {
678                    format!("bytes {acked}-{}/{total}", end - 1)
679                };
680                let bound = Bound::Whole(self.cfg.upload_bound(piece.len()));
681                let rb = self.http.put(session).header("content-range", range).body(piece);
682                Self::dispatch(self.authed(rb).await?, bound).await
683            };
684            let error = match resp {
685                Ok(r) => match r.status().as_u16() {
686                    308 => {
687                        let range = r.headers().get("range").map(|v| v.to_str().unwrap_or(""));
688                        let now = ranges::committed(range, total)
689                            .filter(|n| *n >= acked)
690                            .ok_or_else(|| {
691                                Error::protocol("resumable session reported an impossible offset")
692                            })?;
693                        // A chunk that moved nothing counts against the
694                        // budget like a failure, so a server that never
695                        // advances cannot hold the upload in a loop.
696                        let progressed = now > acked;
697                        acked = now;
698                        if progressed {
699                            stalls = 0;
700                        }
701                        if progressed || failed {
702                            failed = false;
703                            continue;
704                        }
705                        Error::unavailable("resumable session made no progress", true)
706                    }
707                    404 | 410 => return Ok(Session::Gone),
708                    s if is_retriable_status(s) => status_error(r).await,
709                    s => return Ok(Session::Done(s, r)),
710                },
711                Err(e) if e.is_retriable() => e,
712                Err(e) => return Err(e),
713            };
714            stalls += 1;
715            if stalls > self.cfg.max_retries {
716                return Err(error);
717            }
718            failed = true;
719            tokio::time::sleep(backoff(stalls)).await;
720        }
721    }
722
723    async fn list_inner(&self, prefix: &str) -> Result<Vec<Meta>> {
724        self.list_pages(prefix, "items(name,size,generation)", wire::parse_list, |m| &m.key).await
725    }
726
727    async fn list_stat_inner(&self, prefix: &str) -> Result<Vec<Stat>> {
728        self.list_pages(
729            prefix,
730            "items(name,size,generation,contentEncoding)",
731            wire::parse_list_stat,
732            |s| &s.meta.key,
733        )
734        .await
735    }
736
737    /// Every page of a listing of `items` fields, parsed by `parse`.
738    async fn list_pages<M: Ord>(
739        &self,
740        prefix: &str,
741        items: &str,
742        parse: fn(&[u8]) -> Result<Page<M>>,
743        key_of: fn(&M) -> &String,
744    ) -> Result<Vec<M>> {
745        let mut out = Vec::new();
746        let mut page: Option<String> = None;
747        loop {
748            let mut url = format!("{}/o?fields={items},nextPageToken&maxResults=1000", self.api);
749            if !prefix.is_empty() {
750                url.push_str("&prefix=");
751                encode_component(prefix, &mut url);
752            }
753            if let Some(p) = &page {
754                url.push_str("&pageToken=");
755                encode_component(p, &mut url);
756            }
757            let resp = self.send(self.small(), || self.http.get(&url)).await?;
758            if resp.status().as_u16() != 200 {
759                return Err(status_error(resp).await);
760            }
761            let body = self.body(resp).await?;
762            let (items, next) = parse(&body)?;
763            // Objects written by other tools may not fit the key grammar: skip them.
764            out.extend(items.into_iter().filter(|m| {
765                let k = key_of(m);
766                Key::parse(k).is_ok() && k.starts_with(prefix)
767            }));
768            match next {
769                Some(n) if !n.is_empty() => page = Some(n),
770                _ => break,
771            }
772        }
773        out.sort();
774        Ok(out)
775    }
776
777    async fn delete_inner(&self, key: &str) -> Result<()> {
778        let url = self.object_url(key);
779        let resp = self.send(self.small(), || self.http.delete(&url)).await?;
780        match resp.status().as_u16() {
781            200 | 204 | 404 => Ok(()),
782            _ => Err(status_error(resp).await),
783        }
784    }
785}
786
787impl<T: TokenProvider> ObjectStore for Gcs<T> {
788    type Get<'a>
789        = impl Future<Output = Result<Option<Object>>> + 'a
790    where
791        Self: 'a;
792    type GetRange<'a>
793        = impl Future<Output = Result<Bytes>> + 'a
794    where
795        Self: 'a;
796    type Head<'a>
797        = impl Future<Output = Result<Option<Meta>>> + 'a
798    where
799        Self: 'a;
800    type Put<'a>
801        = impl Future<Output = Result<u64>> + 'a
802    where
803        Self: 'a;
804    type List<'a>
805        = impl Future<Output = Result<Vec<Meta>>> + 'a
806    where
807        Self: 'a;
808    type Delete<'a>
809        = impl Future<Output = Result<()>> + 'a
810    where
811        Self: 'a;
812
813    fn get<'a>(&'a self, key: &'a str) -> Self::Get<'a> {
814        async move { self.get_inner(Key::parse(key)?.as_str()).await }
815    }
816
817    fn get_range<'a>(&'a self, key: &'a str, range: Range<u64>) -> Self::GetRange<'a> {
818        async move { self.range_inner(Key::parse(key)?.as_str(), range).await }
819    }
820
821    fn head<'a>(&'a self, key: &'a str) -> Self::Head<'a> {
822        async move { self.head_inner(Key::parse(key)?.as_str()).await }
823    }
824
825    fn put<'a>(&'a self, key: &'a str, body: Bytes, cond: Cond) -> Self::Put<'a> {
826        async move { self.put_inner(Key::parse(key)?.as_str(), body.clone(), body, cond, None).await }
827    }
828
829    fn list<'a>(&'a self, prefix: &'a str) -> Self::List<'a> {
830        async move {
831            check_prefix(prefix)?;
832            self.list_inner(prefix).await
833        }
834    }
835
836    fn delete<'a>(&'a self, key: &'a str) -> Self::Delete<'a> {
837        async move { self.delete_inner(Key::parse(key)?.as_str()).await }
838    }
839
840    async fn put_encoded<'a>(
841        &'a self,
842        key: &'a str,
843        body: Bytes,
844        cond: Cond,
845        encoding: ContentEncoding,
846    ) -> Result<u64> {
847        let key = Key::parse(key)?.as_str();
848        let stored = encoding::encode(encoding, &body)?;
849        self.put_inner(key, body, stored, cond, Some(encoding)).await
850    }
851
852    async fn stat<'a>(&'a self, key: &'a str) -> Result<Option<Stat>> {
853        self.stat_inner(Key::parse(key)?.as_str()).await
854    }
855
856    async fn list_stat<'a>(&'a self, prefix: &'a str) -> Result<Vec<Stat>> {
857        check_prefix(prefix)?;
858        self.list_stat_inner(prefix).await
859    }
860}
861
862/// `a..b` of a body held in memory (both within it by construction).
863fn span(a: u64, b: u64) -> Range<usize> {
864    usize::try_from(a).unwrap_or(usize::MAX)..usize::try_from(b).unwrap_or(usize::MAX)
865}
866
867/// GCS bucket names: 3–222 characters of `[a-z0-9._-]`, starting and ending
868/// with a letter or digit.
869pub(crate) fn is_valid_bucket(b: &str) -> bool {
870    (3..=222).contains(&b.len())
871        && b.bytes().all(|c| {
872            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-')
873        })
874        && b.bytes().next().is_some_and(|c| c.is_ascii_alphanumeric())
875        && b.bytes().last().is_some_and(|c| c.is_ascii_alphanumeric())
876}
877
878fn generation_header(resp: &reqwest::Response) -> Option<u64> {
879    resp.headers()
880        .get("x-goog-generation")
881        .and_then(|v| v.to_str().ok())
882        .and_then(|s| s.parse().ok())
883        .filter(|g| *g != 0)
884}
885
886/// Ask for an object's stored bytes as they are (`Accept-Encoding: gzip`),
887/// so Cloud Storage never decompresses server-side. Only a build that can
888/// decode them asks: without feature `gzip`, a gzip object written by another
889/// tool keeps arriving decoded by the server, exactly as before.
890fn stored_bytes(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
891    if cfg!(feature = "gzip") {
892        req.header("accept-encoding", "gzip")
893    } else {
894        req
895    }
896}
897
898/// The encoding a body was *served* with (`Content-Encoding`); `None` for
899/// bytes as written, including bytes the server already decoded.
900fn served_encoding(resp: &reqwest::Response) -> Result<Option<ContentEncoding>> {
901    match resp.headers().get("content-encoding") {
902        None => Ok(None),
903        Some(v) => ContentEncoding::parse(
904            v.to_str().map_err(|_| Error::protocol("unreadable content-encoding"))?,
905        ),
906    }
907}
908
909/// The encoding an object is *stored* with, from either header (a server may
910/// send only one): `None` for identity.
911fn stored_encoding(resp: &reqwest::Response) -> Option<String> {
912    ["x-goog-stored-content-encoding", "content-encoding"].iter().find_map(|h| {
913        let v = resp.headers().get(*h)?.to_str().unwrap_or("unreadable").trim();
914        (!v.is_empty() && !v.eq_ignore_ascii_case("identity")).then(|| v.chars().take(64).collect())
915    })
916}
917
918/// The stored bytes' CRC-32C, when the body is served as stored.
919fn stored_crc32c(resp: &reqwest::Response) -> Option<u32> {
920    let h = resp.headers();
921    let served = h.get("content-encoding").and_then(|v| v.to_str().ok());
922    // Absent: assume as stored (Cloud Storage always sends it; a server that
923    // does not cannot be transcoding either).
924    let stored = h.get("x-goog-stored-content-encoding").and_then(|v| v.to_str().ok());
925    if stored.is_some_and(|s| Some(s) != served) {
926        return None;
927    }
928    crc32c::from_goog_hash(h.get_all("x-goog-hash").iter().filter_map(|v| v.to_str().ok()))
929}
930
931async fn status_error(resp: reqwest::Response) -> Error {
932    let status = resp.status().as_u16();
933    let body = resp.bytes().await.unwrap_or_default();
934    crate::net::status_error(status, &body)
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn only_a_build_that_decodes_asks_for_stored_bytes() {
943        // Without the codec, asking would turn a gzip object another tool
944        // wrote (served decoded by default) into an unreadable one.
945        let req = stored_bytes(reqwest::Client::new().get("http://127.0.0.1/o")).build().unwrap();
946        let asked = req.headers().get("accept-encoding").and_then(|v| v.to_str().ok());
947        assert_eq!(asked, cfg!(feature = "gzip").then_some("gzip"));
948    }
949
950    #[test]
951    fn session_urls_stay_on_the_upload_origin() {
952        let g = Gcs::new("bkt", crate::token::Static::none(), GcsConfig::default()).unwrap();
953        let ok = "https://storage.googleapis.com/upload/storage/v1/b/bkt/o?uploadType=resumable&upload_id=x";
954        assert!(g.is_upload_url(ok));
955        for bad in [
956            "https://storage.googleapis.com.evil.example/upload/x",
957            "https://storage.googleapis.com@evil.example/upload/x",
958            "https://storage.googleapis.com:8443/upload/x",
959            "http://storage.googleapis.com/upload/x",
960            "https://evil.example/upload/storage/v1/b/bkt/o",
961            "https://storage.googleapis.com",
962            "",
963        ] {
964            assert!(!g.is_upload_url(bad), "{bad}");
965        }
966    }
967
968    #[test]
969    fn upload_bounds_grow_with_the_body() {
970        let c = GcsConfig::default();
971        assert_eq!(c.upload_bound(0), Duration::from_secs(30));
972        // 8 MiB at 64 KiB/s is 128 s on top of the 30 s.
973        assert_eq!(c.upload_bound(8 << 20), Duration::from_secs(158));
974        let floor = GcsConfig { min_throughput: 1, ..GcsConfig::default() };
975        assert!(floor.upload_bound(usize::MAX) > Duration::from_secs(1 << 40), "saturates");
976    }
977
978    #[test]
979    fn bucket_names_and_endpoints() {
980        assert!(is_valid_bucket("my-project-sayasaya-state") && is_valid_bucket("a.b_c"));
981        for bad in ["ab", "-ab", "ab-", "AB", "a/b", &"a".repeat(223)] {
982            assert!(!is_valid_bucket(bad), "{bad}");
983        }
984        let cfg = |e: &str| GcsConfig { endpoint: e.into(), ..GcsConfig::default() };
985        let t = crate::token::Static::none;
986        assert!(
987            Gcs::new("bkt", t(), cfg("http://example.com")).is_err(),
988            "plain http to a remote host is refused"
989        );
990        assert!(Gcs::new("bkt", t(), cfg("http://127.0.0.1:4443")).is_ok());
991        assert!(Gcs::new("bkt", t(), cfg("https://storage.googleapis.com/")).is_ok());
992    }
993}