Skip to main content

s3pe/
client.rs

1//! The client: configuration, the operations, retries and integrity.
2
3use crate::error::{Error, Result};
4use crate::sigv4::{self, Credentials, PresignRequest, SignRequest, EMPTY_SHA256};
5use crate::xml;
6use md5::Md5;
7use sha2::{Digest, Sha256};
8use std::collections::BTreeMap;
9use std::fs::File;
10use std::io::{Read, Seek, SeekFrom, Write};
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16/// Signing region used when none is given.
17pub const DEFAULT_REGION: &str = "us-east-1";
18/// Seconds a single call may take before it is abandoned.
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
20/// Attempts per call: the first and two retries.
21pub const DEFAULT_MAX_TRIES: u32 = 3;
22/// Bodies larger than this go up as a multipart upload (D15).
23pub const DEFAULT_MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
24/// Size of each part of a multipart upload (D15).
25pub const DEFAULT_PART_SIZE: u64 = 16 * 1024 * 1024;
26/// Parts sent at the same time (D15).
27pub const DEFAULT_PARALLEL: usize = 4;
28/// Smallest part S3 accepts, except for the last one.
29pub const MIN_PART_SIZE: u64 = 5 * 1024 * 1024;
30/// Longest validity of a pre-signed URL (seven days).
31pub const MAX_PRESIGN_SECS: u64 = 7 * 24 * 3600;
32
33const TRANSIENT: [u16; 5] = [429, 500, 502, 503, 504];
34
35/// One object as listed or described by the store.
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub struct Object {
38    /// Key inside the bucket.
39    pub key: String,
40    /// Size in bytes.
41    pub size: u64,
42    /// RFC 3339 (listing) or RFC 7231 (`HEAD`), as the server sent it.
43    pub last_modified: String,
44    /// Without the surrounding quotes.
45    pub etag: String,
46    /// Only known after a `HEAD`.
47    pub content_type: Option<String>,
48    /// User metadata (`x-amz-meta-*`, names without the prefix), after a `HEAD`.
49    pub metadata: BTreeMap<String, String>,
50}
51
52/// One level of a bucket: the objects directly under a prefix and the
53/// common prefixes ("folders") below it (D17).
54#[derive(Debug, Clone, Default)]
55pub struct Listing {
56    /// Objects directly under the prefix.
57    pub objects: Vec<Object>,
58    /// Common prefixes, each ending with the delimiter.
59    pub prefixes: Vec<String>,
60}
61
62/// A bucket as listed by the store.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Bucket {
65    /// Bucket name.
66    pub name: String,
67    /// Creation date, as the server sent it.
68    pub created: String,
69}
70
71/// What a completed upload, download or copy reports.
72#[derive(Debug, Clone)]
73pub struct Transfer {
74    /// Bucket of the object.
75    pub bucket: String,
76    /// Key of the object.
77    pub key: String,
78    /// Bytes moved.
79    pub size: u64,
80    /// ETag reported by the server, without quotes.
81    pub etag: String,
82    /// Wall time of the whole call, retries included.
83    pub elapsed: Duration,
84}
85
86impl Transfer {
87    /// `s3://bucket/key`.
88    pub fn url(&self) -> String {
89        format!("s3://{}/{}", self.bucket, self.key)
90    }
91}
92
93/// Options of `write_with` and `upload_with` (D17).
94#[derive(Debug, Clone, Default)]
95pub struct PutOptions {
96    /// Content type; guessed from the key when `None`.
97    pub content_type: Option<String>,
98    /// User metadata, sent as `x-amz-meta-<name>`.
99    pub metadata: Vec<(String, String)>,
100    /// Any other header (`Cache-Control`, `Content-Disposition`,
101    /// `x-amz-storage-class`, `x-amz-server-side-encryption`, …).
102    pub headers: Vec<(String, String)>,
103}
104
105impl PutOptions {
106    /// Options with only a content type.
107    pub fn content_type(ct: &str) -> Self {
108        PutOptions {
109            content_type: Some(ct.to_string()),
110            ..Default::default()
111        }
112    }
113}
114
115/// Options of `sync_up` and `sync_down` (D18).
116#[derive(Debug, Clone, Default)]
117pub struct SyncOptions {
118    /// Remove what exists on the destination but not on the source.
119    pub delete: bool,
120    /// Relative path of the file to transfer last (a manifest).
121    pub last: Option<String>,
122}
123
124/// What a sync did.
125#[derive(Debug, Clone, Default)]
126pub struct SyncReport {
127    /// Relative paths uploaded or downloaded.
128    pub transferred: Vec<String>,
129    /// Relative paths already identical on both sides.
130    pub skipped: Vec<String>,
131    /// Relative paths removed from the destination.
132    pub deleted: Vec<String>,
133    /// Wall time.
134    pub elapsed: Duration,
135}
136
137/// Builds a [`Client`]; anything not set comes from the environment, then
138/// from the defaults (see the crate documentation for the variables).
139#[derive(Debug, Default, Clone)]
140pub struct ClientBuilder {
141    endpoint: Option<String>,
142    region: Option<String>,
143    access_key: Option<String>,
144    secret_key: Option<String>,
145    session_token: Option<String>,
146    bucket: Option<String>,
147    timeout: Option<Duration>,
148    max_tries: Option<u32>,
149    multipart_threshold: Option<u64>,
150    part_size: Option<u64>,
151    parallel: Option<usize>,
152}
153
154fn env(name: &str) -> Option<String> {
155    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
156}
157
158impl ClientBuilder {
159    /// `https://host[:port]`, no path.
160    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
161        self.endpoint = Some(endpoint.into());
162        self
163    }
164    /// Signing region (default `us-east-1`; on AWS it must be the bucket's region).
165    pub fn region(mut self, region: impl Into<String>) -> Self {
166        self.region = Some(region.into());
167        self
168    }
169    /// Access key and secret key.
170    pub fn credentials(
171        mut self,
172        access_key: impl Into<String>,
173        secret_key: impl Into<String>,
174    ) -> Self {
175        self.access_key = Some(access_key.into());
176        self.secret_key = Some(secret_key.into());
177        self
178    }
179    /// Session token, for temporary credentials.
180    pub fn session_token(mut self, token: impl Into<String>) -> Self {
181        self.session_token = Some(token.into());
182        self
183    }
184    /// Bucket used when a key is not an `s3://` URL.
185    pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
186        self.bucket = Some(bucket.into());
187        self
188    }
189    /// Time limit of one call (default 300 s).
190    pub fn timeout(mut self, timeout: Duration) -> Self {
191        self.timeout = Some(timeout);
192        self
193    }
194    /// Attempts per call, at least 1 (default 3).
195    pub fn max_tries(mut self, tries: u32) -> Self {
196        self.max_tries = Some(tries.max(1));
197        self
198    }
199    /// Bodies above this size go up in parts (default 64 MiB).
200    pub fn multipart_threshold(mut self, bytes: u64) -> Self {
201        self.multipart_threshold = Some(bytes);
202        self
203    }
204    /// Part size, at least 5 MiB (default 16 MiB).
205    pub fn part_size(mut self, bytes: u64) -> Self {
206        self.part_size = Some(bytes.max(MIN_PART_SIZE));
207        self
208    }
209    /// Parts sent at the same time, at least 1 (default 4; `S3PE_PARALLEL`).
210    pub fn parallel(mut self, n: usize) -> Self {
211        self.parallel = Some(n.max(1));
212        self
213    }
214
215    /// Resolves the configuration and builds the client. Fails without an
216    /// endpoint, without credentials, or with an endpoint that is not
217    /// `http(s)://host[:port]`.
218    pub fn build(self) -> Result<Client> {
219        let endpoint = self
220            .endpoint
221            .or_else(|| env("S3PE_ENDPOINT"))
222            .or_else(|| env("AWS_ENDPOINT_URL"))
223            .ok_or_else(|| {
224                Error::Config(
225                    "no endpoint: set S3PE_ENDPOINT (or AWS_ENDPOINT_URL) to https://host[:port] \
226                     or pass it to the builder"
227                        .into(),
228                )
229            })?;
230        let (endpoint, host) = parse_endpoint(&endpoint)?;
231        let region = self
232            .region
233            .or_else(|| env("AWS_REGION"))
234            .or_else(|| env("AWS_DEFAULT_REGION"))
235            .unwrap_or_else(|| DEFAULT_REGION.to_string());
236        let access_key = self.access_key.or_else(|| env("AWS_ACCESS_KEY_ID"));
237        let secret_key = self.secret_key.or_else(|| env("AWS_SECRET_ACCESS_KEY"));
238        let (access_key, secret_key) = match (access_key, secret_key) {
239            (Some(a), Some(s)) => (a, s),
240            _ => {
241                return Err(Error::Config(
242                    "no credentials: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY \
243                     or pass them to the builder"
244                        .into(),
245                ))
246            }
247        };
248        let credentials = Credentials {
249            access_key,
250            secret_key,
251            session_token: self.session_token.or_else(|| env("AWS_SESSION_TOKEN")),
252        };
253        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
254        let agent: ureq::Agent = ureq::Agent::config_builder()
255            .http_status_as_error(false)
256            .max_redirects(0)
257            .timeout_per_call(Some(timeout))
258            .timeout_connect(Some(Duration::from_secs(20)))
259            .user_agent(concat!("s3pe/", env!("CARGO_PKG_VERSION")))
260            .build()
261            .into();
262        let env_u64 = |name: &str| env(name).and_then(|v| v.parse::<u64>().ok());
263        Ok(Client {
264            agent,
265            endpoint,
266            host,
267            region,
268            credentials,
269            bucket: self.bucket.or_else(|| env("S3PE_BUCKET")),
270            max_tries: self.max_tries.unwrap_or(DEFAULT_MAX_TRIES),
271            multipart_threshold: self
272                .multipart_threshold
273                .or_else(|| env_u64("S3PE_MULTIPART_THRESHOLD"))
274                .unwrap_or(DEFAULT_MULTIPART_THRESHOLD),
275            part_size: self
276                .part_size
277                .or_else(|| env_u64("S3PE_PART_SIZE").map(|v| v.max(MIN_PART_SIZE)))
278                .unwrap_or(DEFAULT_PART_SIZE),
279            parallel: self
280                .parallel
281                .or_else(|| env_u64("S3PE_PARALLEL").map(|v| (v as usize).max(1)))
282                .unwrap_or(DEFAULT_PARALLEL),
283        })
284    }
285}
286
287/// `(endpoint without trailing slash, host header value)`.
288fn parse_endpoint(raw: &str) -> Result<(String, String)> {
289    let raw = raw.trim().trim_end_matches('/');
290    let (scheme, rest) = raw.split_once("://").ok_or_else(|| {
291        Error::Config(format!(
292            "endpoint must start with http:// or https://: {raw}"
293        ))
294    })?;
295    if scheme != "http" && scheme != "https" {
296        return Err(Error::Config(format!(
297            "endpoint scheme must be http or https: {raw}"
298        )));
299    }
300    if rest.is_empty() || rest.contains('/') || rest.contains('?') {
301        return Err(Error::Config(format!(
302            "endpoint must be scheme://host[:port], no path: {raw}"
303        )));
304    }
305    // Drop a default port so the Host header matches what the HTTP client sends.
306    let host = match (scheme, rest.rsplit_once(':')) {
307        ("https", Some((h, "443"))) | ("http", Some((h, "80"))) => h.to_string(),
308        _ => rest.to_string(),
309    };
310    Ok((format!("{scheme}://{host}"), host))
311}
312
313/// A configured connection to one S3 endpoint.
314///
315/// Build it with [`Client::from_env`] or [`Client::builder`]. Every method
316/// takes a key that is either relative to the client's bucket or a full
317/// `s3://bucket/key` URL.
318#[derive(Debug, Clone)]
319pub struct Client {
320    agent: ureq::Agent,
321    endpoint: String,
322    host: String,
323    region: String,
324    credentials: Credentials,
325    bucket: Option<String>,
326    max_tries: u32,
327    multipart_threshold: u64,
328    part_size: u64,
329    parallel: usize,
330}
331
332enum Payload<'a> {
333    Empty,
334    Bytes(&'a [u8]),
335    File(&'a Path),
336}
337
338struct Hashes {
339    sha256: String,
340    md5: String,
341    len: u64,
342}
343
344fn hash_bytes(data: &[u8]) -> Hashes {
345    Hashes {
346        sha256: hex::encode(Sha256::digest(data)),
347        md5: hex::encode(Md5::digest(data)),
348        len: data.len() as u64,
349    }
350}
351
352fn hash_file(path: &Path) -> Result<Hashes> {
353    let mut f = File::open(path)?;
354    let mut sha = Sha256::new();
355    let mut md = Md5::new();
356    let mut buf = vec![0u8; 1 << 16];
357    let mut len = 0u64;
358    loop {
359        let n = f.read(&mut buf)?;
360        if n == 0 {
361            break;
362        }
363        sha.update(&buf[..n]);
364        md.update(&buf[..n]);
365        len += n as u64;
366    }
367    Ok(Hashes {
368        sha256: hex::encode(sha.finalize()),
369        md5: hex::encode(md.finalize()),
370        len,
371    })
372}
373
374/// Reads `len` bytes at `offset` of a file.
375fn read_range(path: &Path, offset: u64, len: u64) -> Result<Vec<u8>> {
376    let mut f = File::open(path)?;
377    f.seek(SeekFrom::Start(offset))?;
378    let mut buf = vec![0u8; len as usize];
379    f.read_exact(&mut buf)?;
380    Ok(buf)
381}
382
383/// Source of a body: memory or a file.
384#[derive(Clone, Copy)]
385enum Source<'a> {
386    Bytes(&'a [u8]),
387    File(&'a Path),
388}
389
390impl Source<'_> {
391    fn part(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
392        match self {
393            Source::Bytes(b) => Ok(b[offset as usize..(offset + len) as usize].to_vec()),
394            Source::File(p) => read_range(p, offset, len),
395        }
396    }
397}
398
399/// The ETag S3 gives a multipart upload: `md5(concat(part MD5 bytes))-N`.
400pub fn multipart_etag(part_md5s_hex: &[String]) -> String {
401    let mut all = Vec::with_capacity(part_md5s_hex.len() * 16);
402    for h in part_md5s_hex {
403        all.extend(hex::decode(h).unwrap_or_default());
404    }
405    format!("{}-{}", hex::encode(Md5::digest(&all)), part_md5s_hex.len())
406}
407
408/// The ETag the store would give a local file uploaded by this client:
409/// its MD5, or the multipart ETag (D15) when it is above the threshold.
410pub fn expected_etag(path: &Path, threshold: u64, part_size: u64) -> Result<String> {
411    let len = std::fs::metadata(path)?.len();
412    if len <= threshold {
413        return Ok(hash_file(path)?.md5);
414    }
415    let mut f = File::open(path)?;
416    let mut md5s = Vec::new();
417    let mut buf = vec![0u8; 1 << 16];
418    let mut remaining = len;
419    while remaining > 0 {
420        let this = remaining.min(part_size);
421        let mut md = Md5::new();
422        let mut left = this;
423        while left > 0 {
424            let want = left.min(buf.len() as u64) as usize;
425            let n = f.read(&mut buf[..want])?;
426            if n == 0 {
427                break;
428            }
429            md.update(&buf[..n]);
430            left -= n as u64;
431        }
432        md5s.push(hex::encode(md.finalize()));
433        remaining -= this;
434    }
435    Ok(multipart_etag(&md5s))
436}
437
438impl Client {
439    /// A client configured entirely from the environment.
440    pub fn from_env() -> Result<Client> {
441        ClientBuilder::default().build()
442    }
443
444    /// A builder to set some values explicitly; the rest comes from the environment.
445    pub fn builder() -> ClientBuilder {
446        ClientBuilder::default()
447    }
448
449    /// The endpoint, as `scheme://host[:port]`.
450    pub fn endpoint(&self) -> &str {
451        &self.endpoint
452    }
453
454    /// The default bucket, if any.
455    pub fn bucket(&self) -> Option<&str> {
456        self.bucket.as_deref()
457    }
458
459    /// Splits `s3://bucket/key` or applies the default bucket to a bare key.
460    pub fn resolve(&self, key: &str) -> Result<(String, String)> {
461        if let Some(rest) = key.strip_prefix("s3://") {
462            let (bucket, key) = rest.split_once('/').unwrap_or((rest, ""));
463            if bucket.is_empty() {
464                return Err(Error::Config(format!("no bucket in URL: s3://{rest}")));
465            }
466            return Ok((bucket.to_string(), key.to_string()));
467        }
468        let key = key.strip_prefix('/').unwrap_or(key);
469        match &self.bucket {
470            Some(b) => Ok((b.clone(), key.to_string())),
471            None => Err(Error::Config(format!(
472                "no bucket: write the key as s3://bucket/{key}, set S3PE_BUCKET or build the client with a bucket"
473            ))),
474        }
475    }
476
477    fn resolve_object(&self, key: &str, verb: &str) -> Result<(String, String)> {
478        let (bucket, key) = self.resolve(key)?;
479        if key.is_empty() {
480            return Err(Error::Config(format!("no key to {verb} in s3://{bucket}/")));
481        }
482        Ok((bucket, key))
483    }
484
485    /// Reads an object into memory.
486    pub fn read(&self, key: &str) -> Result<Vec<u8>> {
487        let (bucket, key) = self.resolve_object(key, "read")?;
488        let start = Instant::now();
489        let mut resp = self.send("GET", &bucket, &key, &[], Payload::Empty, &[])?;
490        let expected = content_length(&resp);
491        let etag = etag_of(&resp);
492        let mut data = Vec::with_capacity(expected.unwrap_or(0) as usize);
493        resp.body_mut()
494            .as_reader()
495            .read_to_end(&mut data)
496            .map_err(|e| Error::Transport(format!("reading s3://{bucket}/{key}: {e}")))?;
497        check_download(
498            &bucket,
499            &key,
500            data.len() as u64,
501            expected,
502            &etag,
503            Some(&data),
504        )?;
505        log::info!(target: "s3pe", "↓ s3://{bucket}/{key} ({}, {:.1} s)", format_size(data.len() as u64), start.elapsed().as_secs_f64());
506        Ok(data)
507    }
508
509    /// Writes bytes to an object. `content_type` defaults to a guess from
510    /// the key's extension. Large bodies go up in parts (D15).
511    pub fn write(
512        &self,
513        key: &str,
514        body: impl AsRef<[u8]>,
515        content_type: Option<&str>,
516    ) -> Result<Transfer> {
517        let opts = PutOptions {
518            content_type: content_type.map(str::to_string),
519            ..Default::default()
520        };
521        self.write_with(key, body, &opts)
522    }
523
524    /// Writes bytes to an object with metadata and headers (D17).
525    pub fn write_with(
526        &self,
527        key: &str,
528        body: impl AsRef<[u8]>,
529        opts: &PutOptions,
530    ) -> Result<Transfer> {
531        let (bucket, key) = self.resolve_object(key, "write")?;
532        let body = body.as_ref();
533        let start = Instant::now();
534        let t = self.put_source(
535            &bucket,
536            &key,
537            Source::Bytes(body),
538            body.len() as u64,
539            opts,
540            start,
541        )?;
542        log::info!(target: "s3pe", "↑ {} ({}, {:.1} s)", t.url(), format_size(t.size), t.elapsed.as_secs_f64());
543        Ok(t)
544    }
545
546    /// Uploads a file, streaming. `content_type` defaults to a guess from
547    /// the key. A key that is empty or ends with `/` gets the file's name.
548    pub fn upload(
549        &self,
550        path: impl AsRef<Path>,
551        key: &str,
552        content_type: Option<&str>,
553    ) -> Result<Transfer> {
554        let opts = PutOptions {
555            content_type: content_type.map(str::to_string),
556            ..Default::default()
557        };
558        self.upload_with(path, key, &opts)
559    }
560
561    /// Uploads a file with metadata and headers (D17).
562    pub fn upload_with(
563        &self,
564        path: impl AsRef<Path>,
565        key: &str,
566        opts: &PutOptions,
567    ) -> Result<Transfer> {
568        let path = path.as_ref();
569        let (bucket, key) = self.resolve(key)?;
570        let key = if key.is_empty() || key.ends_with('/') {
571            let name = path
572                .file_name()
573                .ok_or_else(|| Error::Config(format!("no file name in {}", path.display())))?
574                .to_string_lossy();
575            format!("{key}{name}")
576        } else {
577            key
578        };
579        let start = Instant::now();
580        let len = std::fs::metadata(path)?.len();
581        let t = self.put_source(&bucket, &key, Source::File(path), len, opts, start)?;
582        log::info!(target: "s3pe", "↑ {} → {} ({}, {:.1} s)", path.display(), t.url(), format_size(t.size), t.elapsed.as_secs_f64());
583        Ok(t)
584    }
585
586    fn put_headers(&self, key: &str, opts: &PutOptions) -> Vec<(String, String)> {
587        let ct = opts
588            .content_type
589            .clone()
590            .unwrap_or_else(|| content_type_for(key).to_string());
591        let mut headers = vec![("content-type".to_string(), ct)];
592        for (k, v) in &opts.metadata {
593            headers.push((format!("x-amz-meta-{}", k.to_ascii_lowercase()), v.clone()));
594        }
595        for (k, v) in &opts.headers {
596            headers.push((k.to_ascii_lowercase(), v.clone()));
597        }
598        headers
599    }
600
601    fn put_source(
602        &self,
603        bucket: &str,
604        key: &str,
605        source: Source,
606        len: u64,
607        opts: &PutOptions,
608        start: Instant,
609    ) -> Result<Transfer> {
610        let headers = self.put_headers(key, opts);
611        if len > self.multipart_threshold {
612            return self.multipart(bucket, key, source, len, &headers, start);
613        }
614        let (payload, hashes) = match source {
615            Source::Bytes(b) => (Payload::Bytes(b), hash_bytes(b)),
616            Source::File(p) => (Payload::File(p), hash_file(p)?),
617        };
618        let resp = self.send_hashed("PUT", bucket, key, &[], payload, &headers, &hashes)?;
619        let etag = etag_of(&resp);
620        check_upload(bucket, key, &hashes.md5, &etag)?;
621        Ok(Transfer {
622            bucket: bucket.into(),
623            key: key.into(),
624            size: len,
625            etag,
626            elapsed: start.elapsed(),
627        })
628    }
629
630    fn multipart(
631        &self,
632        bucket: &str,
633        key: &str,
634        source: Source,
635        len: u64,
636        headers: &[(String, String)],
637        start: Instant,
638    ) -> Result<Transfer> {
639        let uploads = [("uploads".to_string(), String::new())];
640        let mut resp = self.send("POST", bucket, key, &uploads, Payload::Empty, headers)?;
641        let body = read_text(&mut resp, bucket, key)?;
642        let upload_id = xml::first_text(&body, "UploadId").ok_or_else(|| {
643            Error::Transport(format!("no UploadId in the answer for s3://{bucket}/{key}"))
644        })?;
645        log::debug!(target: "s3pe", "multipart upload {upload_id} of s3://{bucket}/{key}: {} bytes in parts of {}", len, self.part_size);
646
647        let n_parts = len.div_ceil(self.part_size) as usize;
648        let next = AtomicUsize::new(0);
649        let results: Mutex<Vec<Option<Result<String>>>> =
650            Mutex::new((0..n_parts).map(|_| None).collect());
651        let workers = self.parallel.min(n_parts).max(1);
652        std::thread::scope(|scope| {
653            for _ in 0..workers {
654                scope.spawn(|| loop {
655                    let i = next.fetch_add(1, Ordering::SeqCst);
656                    if i >= n_parts {
657                        break;
658                    }
659                    // Stop early once another part failed.
660                    if results.lock().unwrap().iter().flatten().any(|r| r.is_err()) {
661                        break;
662                    }
663                    let offset = i as u64 * self.part_size;
664                    let this = (len - offset).min(self.part_size);
665                    let r = self.upload_part(bucket, key, &upload_id, i + 1, source, offset, this);
666                    results.lock().unwrap()[i] = Some(r);
667                });
668            }
669        });
670
671        let results = results.into_inner().unwrap();
672        let mut md5s = Vec::with_capacity(n_parts);
673        for (i, r) in results.into_iter().enumerate() {
674            match r {
675                Some(Ok(md5)) => md5s.push(md5),
676                Some(Err(e)) => {
677                    self.abort_multipart(bucket, key, &upload_id);
678                    return Err(e);
679                }
680                None => {
681                    self.abort_multipart(bucket, key, &upload_id);
682                    return Err(Error::Transport(format!(
683                        "part {} of s3://{bucket}/{key} was not uploaded",
684                        i + 1
685                    )));
686                }
687            }
688        }
689
690        let mut complete = String::from("<CompleteMultipartUpload>");
691        for (i, md5) in md5s.iter().enumerate() {
692            complete.push_str(&format!(
693                "<Part><PartNumber>{}</PartNumber><ETag>\"{}\"</ETag></Part>",
694                i + 1,
695                md5
696            ));
697        }
698        complete.push_str("</CompleteMultipartUpload>");
699        let q = [("uploadId".to_string(), upload_id.clone())];
700        let ct = [("content-type".to_string(), "application/xml".to_string())];
701        let mut resp = match self.send(
702            "POST",
703            bucket,
704            key,
705            &q,
706            Payload::Bytes(complete.as_bytes()),
707            &ct,
708        ) {
709            Ok(r) => r,
710            Err(e) => {
711                self.abort_multipart(bucket, key, &upload_id);
712                return Err(e);
713            }
714        };
715        let body = read_text(&mut resp, bucket, key)?;
716        if let Some((code, message)) = Some(xml::parse_error(&body)).filter(|(c, _)| !c.is_empty())
717        {
718            self.abort_multipart(bucket, key, &upload_id);
719            return Err(Error::Http {
720                status: 200,
721                code,
722                message,
723                bucket: bucket.into(),
724                key: key.into(),
725            });
726        }
727        let etag = xml::first_text(&body, "ETag").unwrap_or_default();
728        let expected = multipart_etag(&md5s);
729        if !etag.is_empty() && !etag.eq_ignore_ascii_case(&expected) {
730            return Err(Error::Integrity(format!(
731                "s3://{bucket}/{key}: multipart ETag {etag} differs from the expected {expected}"
732            )));
733        }
734        Ok(Transfer {
735            bucket: bucket.into(),
736            key: key.into(),
737            size: len,
738            etag,
739            elapsed: start.elapsed(),
740        })
741    }
742
743    #[allow(clippy::too_many_arguments)]
744    fn upload_part(
745        &self,
746        bucket: &str,
747        key: &str,
748        upload_id: &str,
749        number: usize,
750        source: Source,
751        offset: u64,
752        len: u64,
753    ) -> Result<String> {
754        let data = source.part(offset, len)?;
755        let hashes = hash_bytes(&data);
756        let q = [
757            ("partNumber".to_string(), number.to_string()),
758            ("uploadId".to_string(), upload_id.to_string()),
759        ];
760        // An explicit binary content type: some web application firewalls parse
761        // untyped or text bodies as form parameters and refuse large ones.
762        let ct = [(
763            "content-type".to_string(),
764            "application/octet-stream".to_string(),
765        )];
766        let resp = self.send_hashed("PUT", bucket, key, &q, Payload::Bytes(&data), &ct, &hashes)?;
767        let etag = etag_of(&resp);
768        if looks_like_md5(&etag) && !etag.eq_ignore_ascii_case(&hashes.md5) {
769            return Err(Error::Integrity(format!(
770                "s3://{bucket}/{key} part {number}: ETag {etag} differs from the MD5 of what was sent ({})",
771                hashes.md5
772            )));
773        }
774        log::debug!(target: "s3pe", "part {number} of s3://{bucket}/{key}: {} bytes", len);
775        Ok(hashes.md5)
776    }
777
778    fn abort_multipart(&self, bucket: &str, key: &str, upload_id: &str) {
779        let q = [("uploadId".to_string(), upload_id.to_string())];
780        match self.send("DELETE", bucket, key, &q, Payload::Empty, &[]) {
781            Ok(_) => log::warn!(target: "s3pe", "multipart upload of s3://{bucket}/{key} aborted"),
782            Err(e) => {
783                log::warn!(target: "s3pe", "could not abort the multipart upload of s3://{bucket}/{key}: {e}")
784            }
785        }
786    }
787
788    /// Downloads an object to a file, streaming. The file is written next to
789    /// its final name and renamed when complete, so a failure leaves nothing
790    /// half-written at `path`.
791    pub fn download(&self, key: &str, path: impl AsRef<Path>) -> Result<Transfer> {
792        let (bucket, key) = self.resolve_object(key, "download")?;
793        let path = path.as_ref();
794        let start = Instant::now();
795        let mut resp = self.send("GET", &bucket, &key, &[], Payload::Empty, &[])?;
796        let expected = content_length(&resp);
797        let etag = etag_of(&resp);
798        let part = path.with_extension(match path.extension() {
799            Some(e) => format!("{}.s3pe-part", e.to_string_lossy()),
800            None => "s3pe-part".to_string(),
801        });
802        if let Some(dir) = path.parent() {
803            if !dir.as_os_str().is_empty() {
804                std::fs::create_dir_all(dir)?;
805            }
806        }
807        let result = (|| -> Result<(u64, String)> {
808            let mut out = File::create(&part)?;
809            let mut reader = resp.body_mut().as_reader();
810            let mut md = Md5::new();
811            let mut buf = vec![0u8; 1 << 16];
812            let mut n_total = 0u64;
813            loop {
814                let n = reader
815                    .read(&mut buf)
816                    .map_err(|e| Error::Transport(format!("reading s3://{bucket}/{key}: {e}")))?;
817                if n == 0 {
818                    break;
819                }
820                out.write_all(&buf[..n])?;
821                md.update(&buf[..n]);
822                n_total += n as u64;
823            }
824            out.flush()?;
825            Ok((n_total, hex::encode(md.finalize())))
826        })();
827        let (size, md5) = match result {
828            Ok(v) => v,
829            Err(e) => {
830                let _ = std::fs::remove_file(&part);
831                return Err(e);
832            }
833        };
834        if let Err(e) = check_download_md5(&bucket, &key, size, expected, &etag, &md5) {
835            let _ = std::fs::remove_file(&part);
836            return Err(e);
837        }
838        std::fs::rename(&part, path)?;
839        let t = Transfer {
840            bucket,
841            key,
842            size,
843            etag,
844            elapsed: start.elapsed(),
845        };
846        log::info!(target: "s3pe", "↓ {} → {} ({}, {:.1} s)", t.url(), path.display(), format_size(size), t.elapsed.as_secs_f64());
847        Ok(t)
848    }
849
850    /// Lists every object under a prefix (all pages). `prefix` may be an
851    /// `s3://bucket/prefix` URL, a bare prefix, or `""` for the whole bucket.
852    pub fn list(&self, prefix: &str) -> Result<Vec<Object>> {
853        Ok(self.list_with(prefix, None)?.objects)
854    }
855
856    /// Lists one level: the objects directly under the prefix and the
857    /// common prefixes below it (delimiter `/`, D17).
858    pub fn list_dir(&self, prefix: &str) -> Result<Listing> {
859        self.list_with(prefix, Some("/"))
860    }
861
862    /// Lists with an optional delimiter, all pages.
863    pub fn list_with(&self, prefix: &str, delimiter: Option<&str>) -> Result<Listing> {
864        let (bucket, prefix) = self.resolve(prefix)?;
865        let start = Instant::now();
866        let mut listing = Listing::default();
867        let mut token: Option<String> = None;
868        loop {
869            let mut query = vec![("list-type".to_string(), "2".to_string())];
870            if !prefix.is_empty() {
871                query.push(("prefix".into(), prefix.clone()));
872            }
873            if let Some(d) = delimiter {
874                query.push(("delimiter".into(), d.to_string()));
875            }
876            if let Some(t) = &token {
877                query.push(("continuation-token".into(), t.clone()));
878            }
879            let mut resp = self.send("GET", &bucket, "", &query, Payload::Empty, &[])?;
880            let body = read_text(&mut resp, &bucket, &prefix)?;
881            let page = xml::parse_listing(&body).map_err(|e| {
882                Error::Transport(format!("bad listing XML from s3://{bucket}/{prefix}: {e}"))
883            })?;
884            listing.objects.extend(page.objects);
885            listing.prefixes.extend(page.prefixes);
886            match page.next {
887                Some(t) => token = Some(t),
888                None => break,
889            }
890        }
891        let n = listing.objects.len();
892        log::info!(target: "s3pe", "✔ {n} object{} under s3://{bucket}/{prefix} ({:.1} s)", if n == 1 { "" } else { "s" }, start.elapsed().as_secs_f64());
893        Ok(listing)
894    }
895
896    /// Deletes an object. Deleting what does not exist is not an error.
897    pub fn delete(&self, key: &str) -> Result<()> {
898        let (bucket, key) = self.resolve_object(key, "delete")?;
899        self.send("DELETE", &bucket, &key, &[], Payload::Empty, &[])?;
900        log::info!(target: "s3pe", "✖ s3://{bucket}/{key}");
901        Ok(())
902    }
903
904    /// Deletes several objects with `DeleteObjects`, 1000 per request
905    /// (D17). Keys may name different buckets. Any object the store could
906    /// not delete is reported in one error.
907    pub fn delete_many<S: AsRef<str>>(&self, keys: &[S]) -> Result<()> {
908        let mut by_bucket: BTreeMap<String, Vec<String>> = BTreeMap::new();
909        for k in keys {
910            let (b, key) = self.resolve_object(k.as_ref(), "delete")?;
911            by_bucket.entry(b).or_default().push(key);
912        }
913        let mut failed: Vec<String> = Vec::new();
914        for (bucket, keys) in &by_bucket {
915            for chunk in keys.chunks(1000) {
916                let mut body = String::from("<Delete><Quiet>true</Quiet>");
917                for k in chunk {
918                    body.push_str(&format!("<Object><Key>{}</Key></Object>", xml::escape(k)));
919                }
920                body.push_str("</Delete>");
921                let bytes = body.as_bytes();
922                let headers = [
923                    ("content-type".to_string(), "application/xml".to_string()),
924                    (
925                        "content-md5".to_string(),
926                        sigv4::base64(&Md5::digest(bytes)),
927                    ),
928                ];
929                let q = [("delete".to_string(), String::new())];
930                let mut resp =
931                    self.send("POST", bucket, "", &q, Payload::Bytes(bytes), &headers)?;
932                let text = read_text(&mut resp, bucket, "")?;
933                for (k, code, msg) in xml::parse_delete_errors(&text) {
934                    failed.push(format!("s3://{bucket}/{k} ({code}: {msg})"));
935                }
936            }
937            log::info!(target: "s3pe", "✖ {} object{} in s3://{bucket}/", keys.len(), if keys.len() == 1 { "" } else { "s" });
938        }
939        if failed.is_empty() {
940            Ok(())
941        } else {
942            Err(Error::Http {
943                status: 200,
944                code: "DeleteErrors".into(),
945                message: format!("{} not deleted: {}", failed.len(), failed.join(", ")),
946                bucket: by_bucket.keys().next().cloned().unwrap_or_default(),
947                key: String::new(),
948            })
949        }
950    }
951
952    /// Copies an object inside the store (`CopyObject`, D17). Both keys may
953    /// be `s3://` URLs; the copy keeps the source's metadata.
954    pub fn copy(&self, from: &str, to: &str) -> Result<Transfer> {
955        let (sb, sk) = self.resolve_object(from, "copy")?;
956        let (bucket, key) = self.resolve_object(to, "copy to")?;
957        let start = Instant::now();
958        let source = format!(
959            "/{}/{}",
960            sigv4::uri_encode(&sb, false),
961            sigv4::uri_encode(&sk, true)
962        );
963        let headers = [("x-amz-copy-source".to_string(), source)];
964        let mut resp = self.send("PUT", &bucket, &key, &[], Payload::Empty, &headers)?;
965        let body = read_text(&mut resp, &bucket, &key)?;
966        let (code, message) = xml::parse_error(&body);
967        if !code.is_empty() {
968            return Err(Error::Http {
969                status: 200,
970                code,
971                message,
972                bucket,
973                key,
974            });
975        }
976        let etag = xml::first_text(&body, "ETag").unwrap_or_default();
977        let size = self
978            .head(&format!("s3://{bucket}/{key}"))?
979            .map(|o| o.size)
980            .unwrap_or(0);
981        let t = Transfer {
982            bucket,
983            key,
984            size,
985            etag,
986            elapsed: start.elapsed(),
987        };
988        log::info!(target: "s3pe", "⧉ s3://{sb}/{sk} → {} ({}, {:.1} s)", t.url(), format_size(size), t.elapsed.as_secs_f64());
989        Ok(t)
990    }
991
992    /// Metadata of an object, or `None` when it does not exist.
993    pub fn head(&self, key: &str) -> Result<Option<Object>> {
994        let (bucket, key) = self.resolve_object(key, "inspect")?;
995        match self.send("HEAD", &bucket, &key, &[], Payload::Empty, &[]) {
996            Ok(resp) => {
997                let mut metadata = BTreeMap::new();
998                for (name, value) in resp.headers() {
999                    if let Some(short) = name.as_str().strip_prefix("x-amz-meta-") {
1000                        metadata
1001                            .insert(short.to_string(), value.to_str().unwrap_or("").to_string());
1002                    }
1003                }
1004                Ok(Some(Object {
1005                    key,
1006                    size: content_length(&resp).unwrap_or(0),
1007                    last_modified: header(&resp, "last-modified"),
1008                    etag: etag_of(&resp),
1009                    content_type: Some(header(&resp, "content-type")).filter(|s| !s.is_empty()),
1010                    metadata,
1011                }))
1012            }
1013            Err(Error::NotFound { .. }) => Ok(None),
1014            Err(e) => Err(e),
1015        }
1016    }
1017
1018    /// Whether an object exists.
1019    pub fn exists(&self, key: &str) -> Result<bool> {
1020        Ok(self.head(key)?.is_some())
1021    }
1022
1023    /// The buckets the credentials can see.
1024    pub fn buckets(&self) -> Result<Vec<Bucket>> {
1025        let mut resp = self.send("GET", "", "", &[], Payload::Empty, &[])?;
1026        let body = read_text(&mut resp, "", "")?;
1027        xml::parse_buckets(&body).map_err(|e| Error::Transport(format!("bad bucket list XML: {e}")))
1028    }
1029
1030    /// A URL that reads (`GET`) or writes (`PUT`) the object without
1031    /// credentials for `expires` seconds, at most seven days (D16).
1032    pub fn presign(&self, key: &str, method: &str, expires: Duration) -> Result<String> {
1033        let (bucket, key) = self.resolve_object(key, "pre-sign")?;
1034        let method = method.to_ascii_uppercase();
1035        if method != "GET" && method != "PUT" {
1036            return Err(Error::Config(format!(
1037                "presign: method must be GET or PUT, not {method}"
1038            )));
1039        }
1040        let secs = expires.as_secs().clamp(1, MAX_PRESIGN_SECS);
1041        let path = format!(
1042            "/{}/{}",
1043            sigv4::uri_encode(&bucket, false),
1044            sigv4::uri_encode(&key, true)
1045        );
1046        let qs = sigv4::presign(
1047            &PresignRequest {
1048                method: &method,
1049                host: &self.host,
1050                path: &path,
1051                query: &[],
1052                amz_date: &sigv4::amz_date_now(),
1053                expires: secs,
1054            },
1055            &self.credentials,
1056            &self.region,
1057        );
1058        Ok(format!("{}{}?{}", self.endpoint, path, qs))
1059    }
1060
1061    /// Uploads every file under `dir` whose remote ETag differs from the
1062    /// local one or which is missing; see [`SyncOptions`] (D18).
1063    pub fn sync_up(
1064        &self,
1065        dir: impl AsRef<Path>,
1066        prefix: &str,
1067        opts: &SyncOptions,
1068    ) -> Result<SyncReport> {
1069        let dir = dir.as_ref();
1070        let (bucket, prefix) = self.resolve(prefix)?;
1071        let prefix = normalize_prefix(&prefix);
1072        let start = Instant::now();
1073        let mut local = Vec::new();
1074        walk(dir, dir, &mut local)?;
1075        local.sort();
1076        let remote: BTreeMap<String, Object> = self
1077            .list(&format!("s3://{bucket}/{prefix}"))?
1078            .into_iter()
1079            .filter_map(|o| {
1080                let rel = o.key.strip_prefix(&prefix)?.to_string();
1081                Some((rel, o))
1082            })
1083            .collect();
1084
1085        let mut report = SyncReport::default();
1086        let mut queue: Vec<String> = local
1087            .iter()
1088            .filter(|r| Some(*r) != opts.last.as_ref())
1089            .cloned()
1090            .collect();
1091        if let Some(last) = &opts.last {
1092            if local.contains(last) {
1093                queue.push(last.clone());
1094            } else {
1095                return Err(Error::Config(format!(
1096                    "last = {last}: no such file under {}",
1097                    dir.display()
1098                )));
1099            }
1100        }
1101        for rel in queue {
1102            let path = dir.join(&rel);
1103            let same = match remote.get(&rel) {
1104                Some(o) => {
1105                    let len = std::fs::metadata(&path)?.len();
1106                    len == o.size
1107                        && expected_etag(&path, self.multipart_threshold, self.part_size)?
1108                            .eq_ignore_ascii_case(&o.etag)
1109                }
1110                None => false,
1111            };
1112            if same {
1113                report.skipped.push(rel);
1114            } else {
1115                self.upload(&path, &format!("s3://{bucket}/{prefix}{rel}"), None)?;
1116                report.transferred.push(rel);
1117            }
1118        }
1119        if opts.delete {
1120            let gone: Vec<String> = remote
1121                .keys()
1122                .filter(|r| !local.contains(r))
1123                .map(|r| format!("s3://{bucket}/{prefix}{r}"))
1124                .collect();
1125            if !gone.is_empty() {
1126                self.delete_many(&gone)?;
1127                report.deleted = remote
1128                    .keys()
1129                    .filter(|r| !local.contains(r))
1130                    .cloned()
1131                    .collect();
1132            }
1133        }
1134        report.elapsed = start.elapsed();
1135        log::info!(target: "s3pe", "✔ sync {} → s3://{bucket}/{prefix}: {} uploaded, {} unchanged, {} deleted ({:.1} s)", dir.display(), report.transferred.len(), report.skipped.len(), report.deleted.len(), report.elapsed.as_secs_f64());
1136        Ok(report)
1137    }
1138
1139    /// Downloads every object under `prefix` whose local copy is missing or
1140    /// differs; with `delete`, removes local files that are not remote (D18).
1141    pub fn sync_down(
1142        &self,
1143        prefix: &str,
1144        dir: impl AsRef<Path>,
1145        opts: &SyncOptions,
1146    ) -> Result<SyncReport> {
1147        let dir = dir.as_ref();
1148        let (bucket, prefix) = self.resolve(prefix)?;
1149        let prefix = normalize_prefix(&prefix);
1150        let start = Instant::now();
1151        std::fs::create_dir_all(dir)?;
1152        let remote: Vec<(String, Object)> = self
1153            .list(&format!("s3://{bucket}/{prefix}"))?
1154            .into_iter()
1155            .filter_map(|o| {
1156                let rel = o.key.strip_prefix(&prefix)?.to_string();
1157                Some((rel, o))
1158            })
1159            .filter(|(r, _)| !r.is_empty() && !r.ends_with('/'))
1160            .collect();
1161        let mut report = SyncReport::default();
1162        for (rel, o) in &remote {
1163            let path = dir.join(rel);
1164            let same = path.is_file()
1165                && std::fs::metadata(&path)?.len() == o.size
1166                && expected_etag(&path, self.multipart_threshold, self.part_size)?
1167                    .eq_ignore_ascii_case(&o.etag);
1168            if same {
1169                report.skipped.push(rel.clone());
1170            } else {
1171                self.download(&format!("s3://{bucket}/{}", o.key), &path)?;
1172                report.transferred.push(rel.clone());
1173            }
1174        }
1175        if opts.delete {
1176            let mut local = Vec::new();
1177            walk(dir, dir, &mut local)?;
1178            for rel in local {
1179                if !remote.iter().any(|(r, _)| *r == rel) {
1180                    std::fs::remove_file(dir.join(&rel))?;
1181                    report.deleted.push(rel);
1182                }
1183            }
1184        }
1185        report.elapsed = start.elapsed();
1186        log::info!(target: "s3pe", "✔ sync s3://{bucket}/{prefix} → {}: {} downloaded, {} unchanged, {} deleted ({:.1} s)", dir.display(), report.transferred.len(), report.skipped.len(), report.deleted.len(), report.elapsed.as_secs_f64());
1187        Ok(report)
1188    }
1189
1190    fn send(
1191        &self,
1192        method: &str,
1193        bucket: &str,
1194        key: &str,
1195        query: &[(String, String)],
1196        payload: Payload,
1197        headers: &[(String, String)],
1198    ) -> Result<ureq::http::Response<ureq::Body>> {
1199        let hashes = match &payload {
1200            Payload::Empty => Hashes {
1201                sha256: EMPTY_SHA256.into(),
1202                md5: String::new(),
1203                len: 0,
1204            },
1205            Payload::Bytes(b) => hash_bytes(b),
1206            Payload::File(p) => hash_file(p)?,
1207        };
1208        self.send_hashed(method, bucket, key, query, payload, headers, &hashes)
1209    }
1210
1211    #[allow(clippy::too_many_arguments)]
1212    fn send_hashed(
1213        &self,
1214        method: &str,
1215        bucket: &str,
1216        key: &str,
1217        query: &[(String, String)],
1218        payload: Payload,
1219        headers: &[(String, String)],
1220        hashes: &Hashes,
1221    ) -> Result<ureq::http::Response<ureq::Body>> {
1222        let path = if bucket.is_empty() {
1223            "/".to_string()
1224        } else if key.is_empty() {
1225            format!("/{}", sigv4::uri_encode(bucket, false))
1226        } else {
1227            format!(
1228                "/{}/{}",
1229                sigv4::uri_encode(bucket, false),
1230                sigv4::uri_encode(key, true)
1231            )
1232        };
1233        let mut url = format!("{}{}", self.endpoint, path);
1234        let qs = sigv4::canonical_query(query);
1235        if !qs.is_empty() {
1236            url.push('?');
1237            url.push_str(&qs);
1238        }
1239        // Every x-amz-* header is signed; the others travel unsigned.
1240        let signed_extra: Vec<(String, String)> = headers
1241            .iter()
1242            .filter(|(k, _)| k.starts_with("x-amz-"))
1243            .cloned()
1244            .collect();
1245        let unsigned: Vec<(String, String)> = headers
1246            .iter()
1247            .filter(|(k, _)| !k.starts_with("x-amz-"))
1248            .cloned()
1249            .collect();
1250
1251        let mut tries = 0;
1252        loop {
1253            tries += 1;
1254            let amz_date = sigv4::amz_date_now();
1255            let mut signed = sigv4::sign(
1256                &SignRequest {
1257                    method,
1258                    host: &self.host,
1259                    path: &path,
1260                    query,
1261                    payload_hash: &hashes.sha256,
1262                    amz_date: &amz_date,
1263                    extra_headers: &signed_extra,
1264                },
1265                &self.credentials,
1266                &self.region,
1267            );
1268            signed.extend(unsigned.iter().cloned());
1269            let attempt = self.attempt(method, &url, &signed, &payload, hashes.len);
1270            match attempt {
1271                Ok(resp) => {
1272                    let status = resp.status().as_u16();
1273                    if TRANSIENT.contains(&status) && tries < self.max_tries {
1274                        let wait = backoff(tries);
1275                        log::warn!(target: "s3pe", "HTTP {status} from {method} s3://{bucket}/{key}; retrying in {} s ({tries}/{})", wait.as_secs(), self.max_tries);
1276                        std::thread::sleep(wait);
1277                        continue;
1278                    }
1279                    return self.check(resp, method, bucket, key);
1280                }
1281                Err(Error::Transport(msg)) if tries < self.max_tries => {
1282                    let wait = backoff(tries);
1283                    log::warn!(target: "s3pe", "{msg}; retrying in {} s ({tries}/{})", wait.as_secs(), self.max_tries);
1284                    std::thread::sleep(wait);
1285                }
1286                Err(e) => return Err(e),
1287            }
1288        }
1289    }
1290
1291    fn attempt(
1292        &self,
1293        method: &str,
1294        url: &str,
1295        headers: &[(String, String)],
1296        payload: &Payload,
1297        len: u64,
1298    ) -> Result<ureq::http::Response<ureq::Body>> {
1299        let transport = |e: ureq::Error| Error::Transport(format!("{method} {url}: {e}"));
1300        match method {
1301            "PUT" | "POST" => {
1302                let mut req = if method == "PUT" {
1303                    self.agent.put(url)
1304                } else {
1305                    self.agent.post(url)
1306                };
1307                for (k, v) in headers {
1308                    req = req.header(k.as_str(), v.as_str());
1309                }
1310                req = req.header("content-length", len.to_string());
1311                match payload {
1312                    Payload::Bytes(b) => req.send(*b).map_err(transport),
1313                    Payload::File(p) => {
1314                        let f = File::open(p)?;
1315                        req.send(f).map_err(transport)
1316                    }
1317                    Payload::Empty => req.send_empty().map_err(transport),
1318                }
1319            }
1320            _ => {
1321                let mut req = match method {
1322                    "GET" => self.agent.get(url),
1323                    "HEAD" => self.agent.head(url),
1324                    "DELETE" => self.agent.delete(url),
1325                    other => return Err(Error::Config(format!("unsupported method {other}"))),
1326                };
1327                for (k, v) in headers {
1328                    req = req.header(k.as_str(), v.as_str());
1329                }
1330                req.call().map_err(transport)
1331            }
1332        }
1333    }
1334
1335    fn check(
1336        &self,
1337        mut resp: ureq::http::Response<ureq::Body>,
1338        method: &str,
1339        bucket: &str,
1340        key: &str,
1341    ) -> Result<ureq::http::Response<ureq::Body>> {
1342        let status = resp.status().as_u16();
1343        if (200..300).contains(&status) {
1344            return Ok(resp);
1345        }
1346        let (code, message) = if method == "HEAD" {
1347            (String::new(), String::new())
1348        } else {
1349            let body = resp
1350                .body_mut()
1351                .with_config()
1352                .limit(64 * 1024)
1353                .read_to_string()
1354                .unwrap_or_default();
1355            let (code, message) = xml::parse_error(&body);
1356            if code.is_empty() && message.is_empty() && !body.trim().is_empty() {
1357                // not an S3 error document (a proxy page, plain text): keep a glimpse of it
1358                let snippet: String = body.trim().chars().take(200).collect();
1359                (String::new(), snippet)
1360            } else {
1361                (code, message)
1362            }
1363        };
1364        Err(match status {
1365            404 => Error::NotFound {
1366                bucket: bucket.into(),
1367                key: key.into(),
1368            },
1369            403 => Error::AccessDenied {
1370                bucket: bucket.into(),
1371                key: key.into(),
1372                message,
1373            },
1374            _ => Error::Http {
1375                status,
1376                code,
1377                message,
1378                bucket: bucket.into(),
1379                key: key.into(),
1380            },
1381        })
1382    }
1383}
1384
1385/// A prefix that is empty or ends with `/`.
1386fn normalize_prefix(p: &str) -> String {
1387    if p.is_empty() || p.ends_with('/') {
1388        p.to_string()
1389    } else {
1390        format!("{p}/")
1391    }
1392}
1393
1394/// Relative paths (with `/`) of every file under `dir`, recursively.
1395fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
1396    for entry in std::fs::read_dir(dir)? {
1397        let entry = entry?;
1398        let path: PathBuf = entry.path();
1399        if path.is_dir() {
1400            walk(root, &path, out)?;
1401        } else if path.is_file() {
1402            let rel = path
1403                .strip_prefix(root)
1404                .unwrap_or(&path)
1405                .components()
1406                .map(|c| c.as_os_str().to_string_lossy().into_owned())
1407                .collect::<Vec<_>>()
1408                .join("/");
1409            if !rel.ends_with(".s3pe-part")
1410                && !rel
1411                    .rsplit('/')
1412                    .next()
1413                    .unwrap_or("")
1414                    .starts_with(".DS_Store")
1415            {
1416                out.push(rel);
1417            }
1418        }
1419    }
1420    Ok(())
1421}
1422
1423fn backoff(tries: u32) -> Duration {
1424    Duration::from_secs(1u64 << (tries - 1).min(4))
1425}
1426
1427fn header(resp: &ureq::http::Response<ureq::Body>, name: &str) -> String {
1428    resp.headers()
1429        .get(name)
1430        .and_then(|v| v.to_str().ok())
1431        .unwrap_or("")
1432        .to_string()
1433}
1434
1435fn etag_of(resp: &ureq::http::Response<ureq::Body>) -> String {
1436    header(resp, "etag").trim_matches('"').to_string()
1437}
1438
1439fn content_length(resp: &ureq::http::Response<ureq::Body>) -> Option<u64> {
1440    header(resp, "content-length").parse().ok()
1441}
1442
1443fn read_text(
1444    resp: &mut ureq::http::Response<ureq::Body>,
1445    bucket: &str,
1446    key: &str,
1447) -> Result<String> {
1448    resp.body_mut()
1449        .with_config()
1450        .limit(256 * 1024 * 1024)
1451        .read_to_string()
1452        .map_err(|e| Error::Transport(format!("reading s3://{bucket}/{key}: {e}")))
1453}
1454
1455fn looks_like_md5(etag: &str) -> bool {
1456    etag.len() == 32 && etag.bytes().all(|b| b.is_ascii_hexdigit())
1457}
1458
1459fn check_upload(bucket: &str, key: &str, md5: &str, etag: &str) -> Result<()> {
1460    if looks_like_md5(etag) && !etag.eq_ignore_ascii_case(md5) {
1461        return Err(Error::Integrity(format!(
1462            "s3://{bucket}/{key}: the server's ETag {etag} differs from the MD5 of what was sent ({md5})"
1463        )));
1464    }
1465    Ok(())
1466}
1467
1468fn check_download(
1469    bucket: &str,
1470    key: &str,
1471    got: u64,
1472    expected: Option<u64>,
1473    etag: &str,
1474    data: Option<&[u8]>,
1475) -> Result<()> {
1476    if let Some(exp) = expected {
1477        if exp != got {
1478            return Err(Error::Integrity(format!(
1479                "s3://{bucket}/{key}: received {got} bytes, Content-Length said {exp}"
1480            )));
1481        }
1482    }
1483    if let (true, Some(d)) = (looks_like_md5(etag), data) {
1484        let md5 = hex::encode(Md5::digest(d));
1485        if !etag.eq_ignore_ascii_case(&md5) {
1486            return Err(Error::Integrity(format!(
1487                "s3://{bucket}/{key}: MD5 of what arrived ({md5}) differs from the ETag {etag}"
1488            )));
1489        }
1490    }
1491    Ok(())
1492}
1493
1494fn check_download_md5(
1495    bucket: &str,
1496    key: &str,
1497    got: u64,
1498    expected: Option<u64>,
1499    etag: &str,
1500    md5: &str,
1501) -> Result<()> {
1502    check_download(bucket, key, got, expected, "", None)?;
1503    if looks_like_md5(etag) && !etag.eq_ignore_ascii_case(md5) {
1504        return Err(Error::Integrity(format!(
1505            "s3://{bucket}/{key}: MD5 of what arrived ({md5}) differs from the ETag {etag}"
1506        )));
1507    }
1508    Ok(())
1509}
1510
1511/// `12.3 MB`, `1.2 kB`, `512 B` (SI units, one decimal).
1512pub fn format_size(bytes: u64) -> String {
1513    const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
1514    let mut v = bytes as f64;
1515    let mut i = 0;
1516    while v >= 1000.0 && i < UNITS.len() - 1 {
1517        v /= 1000.0;
1518        i += 1;
1519    }
1520    if i == 0 {
1521        format!("{bytes} B")
1522    } else {
1523        format!("{v:.1} {}", UNITS[i])
1524    }
1525}
1526
1527/// The content type a key's extension suggests (`application/octet-stream`
1528/// when unknown). The same table is used by every edition of s3pe.
1529pub fn content_type_for(key: &str) -> &'static str {
1530    let lower = key.to_ascii_lowercase();
1531    let name = lower.rsplit('/').next().unwrap_or(&lower);
1532    if name.ends_with(".csv.gz") || name.ends_with(".tsv.gz") || name.ends_with(".json.gz") {
1533        return "application/gzip";
1534    }
1535    match name.rsplit_once('.').map(|(_, e)| e).unwrap_or("") {
1536        "csv" => "text/csv",
1537        "tsv" => "text/tab-separated-values",
1538        "json" => "application/json",
1539        "geojson" => "application/geo+json",
1540        "parquet" => "application/vnd.apache.parquet",
1541        "gz" => "application/gzip",
1542        "zip" => "application/zip",
1543        "txt" => "text/plain",
1544        "md" => "text/markdown",
1545        "html" | "htm" => "text/html",
1546        "xml" => "application/xml",
1547        "svg" => "image/svg+xml",
1548        "png" => "image/png",
1549        "jpg" | "jpeg" => "image/jpeg",
1550        "gif" => "image/gif",
1551        "webp" => "image/webp",
1552        "pdf" => "application/pdf",
1553        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1554        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1555        "js" => "text/javascript",
1556        "css" => "text/css",
1557        "yaml" | "yml" => "application/yaml",
1558        "toml" => "application/toml",
1559        _ => "application/octet-stream",
1560    }
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566
1567    #[test]
1568    fn endpoints() {
1569        assert_eq!(
1570            parse_endpoint("https://s3.example.org/").unwrap(),
1571            ("https://s3.example.org".into(), "s3.example.org".into())
1572        );
1573        assert_eq!(
1574            parse_endpoint("http://127.0.0.1:9003").unwrap(),
1575            ("http://127.0.0.1:9003".into(), "127.0.0.1:9003".into())
1576        );
1577        assert_eq!(
1578            parse_endpoint("https://x.org:443").unwrap(),
1579            ("https://x.org".into(), "x.org".into())
1580        );
1581        assert!(parse_endpoint("s3.example.org").is_err());
1582        assert!(parse_endpoint("https://s3.example.org/bucket").is_err());
1583    }
1584
1585    #[test]
1586    fn sizes_and_types() {
1587        assert_eq!(format_size(0), "0 B");
1588        assert_eq!(format_size(999), "999 B");
1589        assert_eq!(format_size(1234), "1.2 kB");
1590        assert_eq!(format_size(12_345_678), "12.3 MB");
1591        assert_eq!(content_type_for("raw/series.csv.gz"), "application/gzip");
1592        assert_eq!(
1593            content_type_for("a/b.PARQUET"),
1594            "application/vnd.apache.parquet"
1595        );
1596        assert_eq!(content_type_for("noext"), "application/octet-stream");
1597        assert_eq!(content_type_for("x.geojson"), "application/geo+json");
1598    }
1599
1600    #[test]
1601    fn resolve_keys() {
1602        let c = Client::builder()
1603            .endpoint("https://s3.example.org")
1604            .credentials("k", "s")
1605            .bucket("data")
1606            .build()
1607            .unwrap();
1608        assert_eq!(
1609            c.resolve("raw/x.json").unwrap(),
1610            ("data".into(), "raw/x.json".into())
1611        );
1612        assert_eq!(
1613            c.resolve("/raw/x.json").unwrap(),
1614            ("data".into(), "raw/x.json".into())
1615        );
1616        assert_eq!(
1617            c.resolve("s3://other/k").unwrap(),
1618            ("other".into(), "k".into())
1619        );
1620        assert_eq!(
1621            c.resolve("s3://other").unwrap(),
1622            ("other".into(), "".into())
1623        );
1624        assert!(c.resolve("s3:///k").is_err());
1625        let no_bucket = Client::builder()
1626            .endpoint("https://s3.example.org")
1627            .credentials("k", "s")
1628            .build()
1629            .unwrap();
1630        assert!(matches!(no_bucket.resolve("k"), Err(Error::Config(_))));
1631        std::env::remove_var("S3PE_ENDPOINT");
1632        std::env::remove_var("AWS_ENDPOINT_URL");
1633        assert!(matches!(
1634            Client::builder().credentials("k", "s").build(),
1635            Err(Error::Config(_))
1636        ));
1637    }
1638
1639    #[test]
1640    fn multipart_etags() {
1641        // md5("a") = 0cc175b9c0f1b6a831c399e269772661, md5("b") = 92eb5ffee6ae2fec3ad71c777531578f
1642        let e = multipart_etag(&[
1643            "0cc175b9c0f1b6a831c399e269772661".into(),
1644            "92eb5ffee6ae2fec3ad71c777531578f".into(),
1645        ]);
1646        assert!(e.ends_with("-2") && e.len() == 34, "{e}");
1647        let dir = std::env::temp_dir().join(format!("s3pe-etag-{}", std::process::id()));
1648        std::fs::create_dir_all(&dir).unwrap();
1649        let f = dir.join("f.bin");
1650        std::fs::write(&f, vec![7u8; 3000]).unwrap();
1651        assert_eq!(
1652            expected_etag(&f, 5000, MIN_PART_SIZE).unwrap(),
1653            hex::encode(Md5::digest(vec![7u8; 3000]))
1654        );
1655        let parts = expected_etag(&f, 1000, MIN_PART_SIZE).unwrap();
1656        // part_size is clamped by the builder, not here: 3000 bytes in one 5 MiB part
1657        assert!(parts.ends_with("-1"));
1658        std::fs::remove_dir_all(&dir).unwrap();
1659    }
1660
1661    #[test]
1662    fn prefixes_and_walk() {
1663        assert_eq!(normalize_prefix(""), "");
1664        assert_eq!(normalize_prefix("a"), "a/");
1665        assert_eq!(normalize_prefix("a/"), "a/");
1666        let dir = std::env::temp_dir().join(format!("s3pe-walk-{}", std::process::id()));
1667        std::fs::create_dir_all(dir.join("sub/deeper")).unwrap();
1668        std::fs::write(dir.join("a.txt"), "a").unwrap();
1669        std::fs::write(dir.join("sub/deeper/b.txt"), "b").unwrap();
1670        std::fs::write(dir.join("c.s3pe-part"), "x").unwrap();
1671        let mut out = Vec::new();
1672        walk(&dir, &dir, &mut out).unwrap();
1673        out.sort();
1674        assert_eq!(out, vec!["a.txt", "sub/deeper/b.txt"]);
1675        std::fs::remove_dir_all(&dir).unwrap();
1676    }
1677}