Skip to main content

isb_apps/
s3.rs

1//! A small S3 client for backups: AWS Signature Version 4 over ring and
2//! ureq, no SDK.
3//!
4//! Path-style (`https://endpoint/bucket/key`, what MinIO and most
5//! self-hosted stores want) or virtual-hosted (`https://bucket.endpoint/key`)
6//! addressing. Uploads stream: [`Upload`] buffers one part at a time
7//! ([`PART_SIZE`]), sends a single `PUT` when the whole object fit in one
8//! part, and a multipart upload otherwise, aborted if anything fails.
9//! Downloads are a reader over the response body.
10
11use std::io::{Read, Write};
12use std::time::Duration;
13
14use ring::{digest, hmac};
15
16use crate::error::{Error, Result};
17
18/// Bytes buffered per part of a multipart upload. S3 wants at least 5 MiB
19/// per part (but the last) and at most 10,000 parts: up to ~156 GiB.
20pub const PART_SIZE: usize = 16 << 20;
21
22const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
23
24/// Credentials for signing.
25#[derive(Clone)]
26pub struct Credentials {
27    pub access_key: String,
28    pub secret_key: String,
29}
30
31impl std::fmt::Debug for Credentials {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("Credentials")
34            .field("access_key", &self.access_key)
35            .field("secret_key", &"<redacted>")
36            .finish()
37    }
38}
39
40// --- Signature Version 4 ---------------------------------------------------
41
42fn hex(b: &[u8]) -> String {
43    b.iter().map(|x| format!("{x:02x}")).collect()
44}
45
46pub fn sha256_hex(b: &[u8]) -> String {
47    hex(digest::digest(&digest::SHA256, b).as_ref())
48}
49
50fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
51    let k = hmac::Key::new(hmac::HMAC_SHA256, key);
52    hmac::sign(&k, data).as_ref().to_vec()
53}
54
55/// The signing key for one day, region and service.
56pub fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
57    let k = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
58    let k = hmac_sha256(&k, region.as_bytes());
59    let k = hmac_sha256(&k, service.as_bytes());
60    hmac_sha256(&k, b"aws4_request")
61}
62
63/// URI-encode per SigV4: unreserved characters stay, everything else is
64/// `%XX` (uppercase); `/` stays only when `keep_slash`.
65pub fn uri_encode(s: &str, keep_slash: bool) -> String {
66    let mut out = String::with_capacity(s.len());
67    for b in s.bytes() {
68        match b {
69            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
70                out.push(b as char)
71            }
72            b'/' if keep_slash => out.push('/'),
73            _ => out.push_str(&format!("%{b:02X}")),
74        }
75    }
76    out
77}
78
79/// What a request signs: method, the encoded path, the query pairs (not
80/// encoded), headers (lowercase names) and the payload hash.
81pub struct ToSign<'a> {
82    pub method: &'a str,
83    /// Already URI-encoded (`/bucket/my%20key`).
84    pub path: &'a str,
85    pub query: &'a [(String, String)],
86    pub headers: &'a [(String, String)],
87    pub payload_sha256: &'a str,
88}
89
90/// The canonical request, the string to sign and the `Authorization` value.
91pub struct Signed {
92    pub canonical_request: String,
93    pub string_to_sign: String,
94    pub authorization: String,
95}
96
97/// Sign a request. `amz_date` is `YYYYMMDDTHHMMSSZ`; the request must carry
98/// it (as `x-amz-date`) among `headers`.
99pub fn sign(
100    r: &ToSign,
101    creds: &Credentials,
102    region: &str,
103    service: &str,
104    amz_date: &str,
105) -> Signed {
106    let mut q: Vec<(String, String)> = r
107        .query
108        .iter()
109        .map(|(k, v)| (uri_encode(k, false), uri_encode(v, false)))
110        .collect();
111    q.sort();
112    let query = q
113        .iter()
114        .map(|(k, v)| format!("{k}={v}"))
115        .collect::<Vec<_>>()
116        .join("&");
117    let mut h: Vec<(String, String)> = r
118        .headers
119        .iter()
120        .map(|(k, v)| {
121            // Trim, and fold runs of spaces as SigV4 asks.
122            let v = v.split_whitespace().collect::<Vec<_>>().join(" ");
123            (k.to_ascii_lowercase(), v)
124        })
125        .collect();
126    h.sort();
127    let canonical_headers: String = h.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
128    let signed_headers = h
129        .iter()
130        .map(|(k, _)| k.as_str())
131        .collect::<Vec<_>>()
132        .join(";");
133    let canonical_request = format!(
134        "{}\n{}\n{query}\n{canonical_headers}\n{signed_headers}\n{}",
135        r.method, r.path, r.payload_sha256
136    );
137    let date = &amz_date[..8];
138    let scope = format!("{date}/{region}/{service}/aws4_request");
139    let string_to_sign = format!(
140        "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
141        sha256_hex(canonical_request.as_bytes())
142    );
143    let key = signing_key(&creds.secret_key, date, region, service);
144    let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes()));
145    let authorization = format!(
146        "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
147        creds.access_key
148    );
149    Signed {
150        canonical_request,
151        string_to_sign,
152        authorization,
153    }
154}
155
156fn amz_now() -> String {
157    let t = std::time::SystemTime::now()
158        .duration_since(std::time::UNIX_EPOCH)
159        .map(|d| d.as_secs() as i64)
160        .unwrap_or(0);
161    crate::cron::compact_utc(t)
162}
163
164// --- the client ------------------------------------------------------------
165
166/// Where objects go.
167#[derive(Debug, Clone)]
168pub struct Bucket {
169    /// `https://s3.eu-central-1.amazonaws.com`, `http://127.0.0.1:9000`.
170    pub endpoint: String,
171    pub region: String,
172    pub bucket: String,
173    /// `https://endpoint/bucket/key` rather than `https://bucket.endpoint/key`.
174    pub path_style: bool,
175    pub creds: Credentials,
176}
177
178/// One listed object.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Object {
181    pub key: String,
182    pub size: u64,
183    pub last_modified: String,
184}
185
186#[derive(Clone)]
187pub struct Client {
188    b: Bucket,
189    scheme: String,
190    /// `host[:port]` as sent in the Host header.
191    host: String,
192    /// The path prefix before keys: `/bucket` (path style) or ``.
193    base_path: String,
194    agent: ureq::Agent,
195}
196
197/// The endpoint's scheme and host, refusing anything but a bare origin.
198fn parse_endpoint(e: &str) -> Result<(String, String)> {
199    let bad = |why: &str| Error::invalid(format!("endpoint {e:?}: {why}"));
200    let (scheme, rest) = e
201        .split_once("://")
202        .ok_or_else(|| bad("give a URL, https://host[:port]"))?;
203    if scheme != "https" && scheme != "http" {
204        return Err(bad("http or https only"));
205    }
206    let host = rest.trim_end_matches('/');
207    if host.is_empty() || host.contains(['/', '?', '#', '@', ' ']) {
208        return Err(bad("an origin only (scheme, host and port; no path)"));
209    }
210    // The default port is not part of the Host header ureq sends.
211    let host = match (scheme, host.rsplit_once(':')) {
212        ("https", Some((h, "443"))) | ("http", Some((h, "80"))) => h.to_string(),
213        _ => host.to_string(),
214    };
215    Ok((scheme.to_string(), host))
216}
217
218/// An S3 error reply's `Code` and `Message`.
219fn s3_error(body: &str) -> String {
220    let code = xml_text(body, "Code").unwrap_or_default();
221    let msg = xml_text(body, "Message").unwrap_or_default();
222    match (code.is_empty(), msg.is_empty()) {
223        (true, true) => body.chars().take(300).collect(),
224        _ => format!("{code}: {msg}"),
225    }
226}
227
228/// The text of the first `<tag>...</tag>` in `xml`, unescaped.
229pub fn xml_text(xml: &str, tag: &str) -> Option<String> {
230    let open = format!("<{tag}>");
231    let close = format!("</{tag}>");
232    let s = xml.find(&open)? + open.len();
233    let e = xml[s..].find(&close)? + s;
234    Some(xml_unescape(&xml[s..e]))
235}
236
237/// Each `<tag>...</tag>` block's inner text, in order.
238pub fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
239    let open = format!("<{tag}>");
240    let close = format!("</{tag}>");
241    let mut out = Vec::new();
242    let mut rest = xml;
243    while let Some(i) = rest.find(&open) {
244        let after = &rest[i + open.len()..];
245        let Some(j) = after.find(&close) else { break };
246        out.push(&after[..j]);
247        rest = &after[j + close.len()..];
248    }
249    out
250}
251
252pub fn xml_unescape(s: &str) -> String {
253    s.replace("&lt;", "<")
254        .replace("&gt;", ">")
255        .replace("&quot;", "\"")
256        .replace("&apos;", "'")
257        .replace("&#34;", "\"")
258        .replace("&#39;", "'")
259        .replace("&amp;", "&")
260}
261
262fn xml_escape(s: &str) -> String {
263    s.replace('&', "&amp;")
264        .replace('<', "&lt;")
265        .replace('>', "&gt;")
266        .replace('"', "&quot;")
267}
268
269/// The body of a CompleteMultipartUpload request.
270pub fn complete_body(parts: &[(u32, String)]) -> String {
271    let mut s = String::from("<CompleteMultipartUpload>");
272    for (n, etag) in parts {
273        s.push_str(&format!(
274            "<Part><PartNumber>{n}</PartNumber><ETag>{}</ETag></Part>",
275            xml_escape(etag)
276        ));
277    }
278    s.push_str("</CompleteMultipartUpload>");
279    s
280}
281
282impl Client {
283    pub fn new(b: Bucket) -> Result<Client> {
284        let (scheme, host) = parse_endpoint(&b.endpoint)?;
285        validate_bucket(&b.bucket)?;
286        if b.region.is_empty() {
287            return Err(Error::invalid("region: required (us-east-1 for MinIO)"));
288        }
289        let (host, base_path) = if b.path_style {
290            (host, format!("/{}", uri_encode(&b.bucket, false)))
291        } else {
292            (format!("{}.{host}", b.bucket), String::new())
293        };
294        let agent = ureq::Agent::config_builder()
295            .http_status_as_error(false)
296            .max_redirects(0)
297            .timeout_connect(Some(Duration::from_secs(30)))
298            .timeout_recv_response(Some(Duration::from_secs(300)))
299            .timeout_send_body(Some(Duration::from_secs(1800)))
300            // A restore reads a whole dump through one response.
301            .timeout_recv_body(Some(Duration::from_secs(12 * 3600)))
302            .user_agent(concat!("isb/", env!("CARGO_PKG_VERSION")))
303            .build()
304            .into();
305        Ok(Client {
306            b,
307            scheme,
308            host,
309            base_path,
310            agent,
311        })
312    }
313
314    pub fn bucket(&self) -> &Bucket {
315        &self.b
316    }
317
318    fn path(&self, key: &str) -> String {
319        if key.is_empty() {
320            return if self.base_path.is_empty() {
321                "/".into()
322            } else {
323                self.base_path.clone()
324            };
325        }
326        format!("{}/{}", self.base_path, uri_encode(key, true))
327    }
328
329    /// Send a signed request; `(status, response)`.
330    fn send(
331        &self,
332        method: &str,
333        key: &str,
334        query: &[(String, String)],
335        extra: &[(&str, &str)],
336        body: &[u8],
337    ) -> Result<ureq::http::Response<ureq::Body>> {
338        let path = self.path(key);
339        let date = amz_now();
340        let payload = if body.is_empty() {
341            EMPTY_SHA256.to_string()
342        } else {
343            sha256_hex(body)
344        };
345        let mut headers = vec![
346            ("host".to_string(), self.host.clone()),
347            ("x-amz-content-sha256".to_string(), payload.clone()),
348            ("x-amz-date".to_string(), date.clone()),
349        ];
350        for (k, v) in extra {
351            headers.push((k.to_string(), v.to_string()));
352        }
353        let signed = sign(
354            &ToSign {
355                method,
356                path: &path,
357                query,
358                headers: &headers,
359                payload_sha256: &payload,
360            },
361            &self.b.creds,
362            &self.b.region,
363            "s3",
364            &date,
365        );
366        let mut url = format!("{}://{}{path}", self.scheme, self.host);
367        if !query.is_empty() {
368            url.push('?');
369            url.push_str(
370                &query
371                    .iter()
372                    .map(|(k, v)| {
373                        if v.is_empty() && k == "uploads" {
374                            k.clone()
375                        } else {
376                            format!("{}={}", uri_encode(k, false), uri_encode(v, false))
377                        }
378                    })
379                    .collect::<Vec<_>>()
380                    .join("&"),
381            );
382        }
383        let mut req = ureq::http::Request::builder().method(method).uri(&url);
384        for (k, v) in &headers {
385            if k != "host" {
386                req = req.header(k.as_str(), v.as_str());
387            }
388        }
389        req = req.header("authorization", signed.authorization.as_str());
390        let built = |e: ureq::http::Error| Error::invalid(format!("s3 {method} {key}: {e}"));
391        let r = if body.is_empty() && matches!(method, "GET" | "HEAD" | "DELETE") {
392            self.agent.run(req.body(()).map_err(built)?)
393        } else {
394            self.agent.run(req.body(body).map_err(built)?)
395        };
396        r.map_err(|e| Error::invalid(format!("s3 {method} {}: {e}", self.describe(key))))
397    }
398
399    fn describe(&self, key: &str) -> String {
400        format!("s3://{}/{key}", self.b.bucket)
401    }
402
403    fn fail(&self, step: &str, key: &str, mut r: ureq::http::Response<ureq::Body>) -> Error {
404        let status = r.status().as_u16();
405        let body = r
406            .body_mut()
407            .with_config()
408            .limit(64 << 10)
409            .read_to_string()
410            .unwrap_or_default();
411        Error::invalid(format!(
412            "s3 {step} {}: HTTP {status} {}",
413            self.describe(key),
414            s3_error(&body)
415        ))
416    }
417
418    /// Upload one object in a single request.
419    pub fn put(&self, key: &str, body: &[u8]) -> Result<()> {
420        let r = self.send("PUT", key, &[], &[], body)?;
421        if r.status().is_success() {
422            Ok(())
423        } else {
424            Err(self.fail("put", key, r))
425        }
426    }
427
428    /// Create the bucket; one that exists and is ours is fine.
429    pub fn create_bucket(&self) -> Result<()> {
430        let mut r = self.send("PUT", "", &[], &[], &[])?;
431        if r.status().is_success() {
432            return Ok(());
433        }
434        let body = r
435            .body_mut()
436            .with_config()
437            .limit(64 << 10)
438            .read_to_string()
439            .unwrap_or_default();
440        if body.contains("BucketAlreadyOwnedByYou") {
441            return Ok(());
442        }
443        Err(Error::invalid(format!(
444            "s3 create bucket {}: HTTP {} {}",
445            self.b.bucket,
446            r.status().as_u16(),
447            s3_error(&body)
448        )))
449    }
450
451    /// The object's size, or `None` when it does not exist.
452    pub fn head(&self, key: &str) -> Result<Option<u64>> {
453        let r = self.send("HEAD", key, &[], &[], &[])?;
454        match r.status().as_u16() {
455            200 => Ok(Some(
456                r.headers()
457                    .get("content-length")
458                    .and_then(|v| v.to_str().ok())
459                    .and_then(|v| v.parse().ok())
460                    .unwrap_or(0),
461            )),
462            404 => Ok(None),
463            s => Err(Error::invalid(format!(
464                "s3 head {}: HTTP {s}",
465                self.describe(key)
466            ))),
467        }
468    }
469
470    /// A reader over the object's bytes.
471    pub fn get(&self, key: &str) -> Result<(u64, Box<dyn Read + Send>)> {
472        let r = self.send("GET", key, &[], &[], &[])?;
473        if !r.status().is_success() {
474            return Err(self.fail("get", key, r));
475        }
476        let len = r
477            .headers()
478            .get("content-length")
479            .and_then(|v| v.to_str().ok())
480            .and_then(|v| v.parse().ok())
481            .unwrap_or(0);
482        Ok((len, Box::new(r.into_body().into_reader())))
483    }
484
485    pub fn delete(&self, key: &str) -> Result<()> {
486        let r = self.send("DELETE", key, &[], &[], &[])?;
487        if r.status().is_success() || r.status().as_u16() == 404 {
488            Ok(())
489        } else {
490            Err(self.fail("delete", key, r))
491        }
492    }
493
494    /// Every object under `prefix` (ListObjectsV2, following continuation).
495    pub fn list(&self, prefix: &str) -> Result<Vec<Object>> {
496        let mut out = Vec::new();
497        let mut token: Option<String> = None;
498        for _ in 0..1000 {
499            let mut q = vec![
500                ("list-type".to_string(), "2".to_string()),
501                ("prefix".to_string(), prefix.to_string()),
502            ];
503            if let Some(t) = &token {
504                q.push(("continuation-token".to_string(), t.clone()));
505            }
506            let mut r = self.send("GET", "", &q, &[], &[])?;
507            if !r.status().is_success() {
508                return Err(self.fail("list", prefix, r));
509            }
510            let body = r
511                .body_mut()
512                .with_config()
513                .limit(32 << 20)
514                .read_to_string()
515                .map_err(|e| Error::invalid(format!("s3 list {prefix}: {e}")))?;
516            out.extend(parse_list(&body));
517            match (
518                xml_text(&body, "IsTruncated").as_deref(),
519                xml_text(&body, "NextContinuationToken"),
520            ) {
521                (Some("true"), Some(t)) => token = Some(t),
522                _ => return Ok(out),
523            }
524        }
525        Err(Error::invalid(format!(
526            "s3 list {prefix}: more than 1000 pages"
527        )))
528    }
529
530    fn create_multipart(&self, key: &str, content_type: &str) -> Result<String> {
531        let q = [("uploads".to_string(), String::new())];
532        let mut r = self.send("POST", key, &q, &[("content-type", content_type)], &[])?;
533        if !r.status().is_success() {
534            return Err(self.fail("create multipart upload", key, r));
535        }
536        let body = r
537            .body_mut()
538            .with_config()
539            .limit(1 << 20)
540            .read_to_string()
541            .unwrap_or_default();
542        xml_text(&body, "UploadId").ok_or_else(|| {
543            Error::invalid(format!(
544                "s3 create multipart upload {}: no UploadId in the reply",
545                self.describe(key)
546            ))
547        })
548    }
549
550    fn upload_part(&self, key: &str, upload: &str, n: u32, body: &[u8]) -> Result<String> {
551        let q = [
552            ("partNumber".to_string(), n.to_string()),
553            ("uploadId".to_string(), upload.to_string()),
554        ];
555        let r = self.send("PUT", key, &q, &[], body)?;
556        if !r.status().is_success() {
557            return Err(self.fail(&format!("upload part {n}"), key, r));
558        }
559        r.headers()
560            .get("etag")
561            .and_then(|v| v.to_str().ok())
562            .map(String::from)
563            .ok_or_else(|| {
564                Error::invalid(format!(
565                    "s3 upload part {n} {}: no ETag",
566                    self.describe(key)
567                ))
568            })
569    }
570
571    fn complete_multipart(&self, key: &str, upload: &str, parts: &[(u32, String)]) -> Result<()> {
572        let q = [("uploadId".to_string(), upload.to_string())];
573        let body = complete_body(parts);
574        let mut r = self.send(
575            "POST",
576            key,
577            &q,
578            &[("content-type", "application/xml")],
579            body.as_bytes(),
580        )?;
581        if !r.status().is_success() {
582            return Err(self.fail("complete multipart upload", key, r));
583        }
584        // S3 can answer 200 with an error document.
585        let text = r
586            .body_mut()
587            .with_config()
588            .limit(1 << 20)
589            .read_to_string()
590            .unwrap_or_default();
591        if text.contains("<Error>") {
592            return Err(Error::invalid(format!(
593                "s3 complete multipart upload {}: {}",
594                self.describe(key),
595                s3_error(&text)
596            )));
597        }
598        Ok(())
599    }
600
601    fn abort_multipart(&self, key: &str, upload: &str) {
602        let q = [("uploadId".to_string(), upload.to_string())];
603        let _ = self.send("DELETE", key, &q, &[], &[]);
604    }
605
606    /// Start a streaming upload of `key`.
607    pub fn upload(&self, key: &str, content_type: &str) -> Upload {
608        Upload {
609            c: self.clone(),
610            key: key.to_string(),
611            content_type: content_type.to_string(),
612            buf: Vec::new(),
613            upload_id: None,
614            parts: Vec::new(),
615            total: 0,
616            part_size: PART_SIZE,
617            failed: None,
618        }
619    }
620}
621
622fn parse_list(body: &str) -> Vec<Object> {
623    xml_blocks(body, "Contents")
624        .into_iter()
625        .filter_map(|c| {
626            Some(Object {
627                key: xml_text(c, "Key")?,
628                size: xml_text(c, "Size")
629                    .and_then(|s| s.parse().ok())
630                    .unwrap_or(0),
631                last_modified: xml_text(c, "LastModified").unwrap_or_default(),
632            })
633        })
634        .collect()
635}
636
637/// A bucket name S3 accepts (3-63 of `[a-z0-9.-]`).
638pub fn validate_bucket(b: &str) -> Result<()> {
639    let ok = (3..=63).contains(&b.len())
640        && b.bytes()
641            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'.' || c == b'-')
642        && b.starts_with(|c: char| c.is_ascii_alphanumeric())
643        && b.ends_with(|c: char| c.is_ascii_alphanumeric());
644    if ok {
645        Ok(())
646    } else {
647        Err(Error::invalid(format!(
648            "bucket {b:?}: 3-63 characters of [a-z0-9.-]"
649        )))
650    }
651}
652
653/// A streaming upload: write to it, then [`Upload::finish`]. Holds at most
654/// one part in memory. Dropped unfinished, a multipart upload is aborted.
655pub struct Upload {
656    c: Client,
657    key: String,
658    content_type: String,
659    buf: Vec<u8>,
660    upload_id: Option<String>,
661    parts: Vec<(u32, String)>,
662    total: u64,
663    part_size: usize,
664    failed: Option<String>,
665}
666
667impl Upload {
668    /// Use smaller parts (tests; S3 itself wants at least 5 MiB).
669    pub fn part_size(mut self, n: usize) -> Self {
670        self.part_size = n.max(1);
671        self
672    }
673
674    fn flush_part(&mut self) -> Result<()> {
675        if self.upload_id.is_none() {
676            self.upload_id = Some(self.c.create_multipart(&self.key, &self.content_type)?);
677        }
678        let id = self.upload_id.clone().unwrap_or_default();
679        let n = self.parts.len() as u32 + 1;
680        if n > 10_000 {
681            return Err(Error::invalid("s3: more than 10,000 parts"));
682        }
683        let etag = self.c.upload_part(&self.key, &id, n, &self.buf)?;
684        self.parts.push((n, etag));
685        self.buf.clear();
686        Ok(())
687    }
688
689    /// Send what is left and complete the object. Returns its size.
690    pub fn finish(mut self) -> Result<u64> {
691        if let Some(e) = self.failed.take() {
692            return Err(Error::invalid(e));
693        }
694        match self.upload_id.clone() {
695            None => {
696                let body = std::mem::take(&mut self.buf);
697                self.c.put(&self.key, &body)?;
698            }
699            Some(id) => {
700                if !self.buf.is_empty() {
701                    self.flush_part()?;
702                }
703                let parts = std::mem::take(&mut self.parts);
704                self.c.complete_multipart(&self.key, &id, &parts)?;
705                self.upload_id = None;
706            }
707        }
708        Ok(self.total)
709    }
710}
711
712impl Write for Upload {
713    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
714        if let Some(e) = &self.failed {
715            return Err(std::io::Error::other(e.clone()));
716        }
717        let room = self.part_size - self.buf.len();
718        let n = data.len().min(room);
719        self.buf.extend_from_slice(&data[..n]);
720        self.total += n as u64;
721        if self.buf.len() >= self.part_size {
722            if let Err(e) = self.flush_part() {
723                self.failed = Some(e.to_string());
724                return Err(std::io::Error::other(e.to_string()));
725            }
726        }
727        Ok(n)
728    }
729
730    fn flush(&mut self) -> std::io::Result<()> {
731        Ok(())
732    }
733}
734
735impl Drop for Upload {
736    fn drop(&mut self) {
737        if let Some(id) = self.upload_id.take() {
738            self.c.abort_multipart(&self.key, &id);
739        }
740    }
741}
742
743#[cfg(test)]
744pub(crate) mod tests {
745    use super::*;
746    use std::io::BufRead;
747    use std::sync::{Arc, Mutex};
748
749    fn creds(a: &str, s: &str) -> Credentials {
750        Credentials {
751            access_key: a.into(),
752            secret_key: s.into(),
753        }
754    }
755
756    /// AWS's published example of the derived signing key (Signature
757    /// Version 4 documentation, "Deriving the signing key").
758    #[test]
759    fn signing_key_vector() {
760        let k = signing_key(
761            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
762            "20120215",
763            "us-east-1",
764            "iam",
765        );
766        assert_eq!(
767            hex(&k),
768            "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
769        );
770    }
771
772    /// The `get-vanilla` case of AWS's SigV4 test suite.
773    #[test]
774    fn suite_get_vanilla() {
775        let headers = vec![
776            ("Host".to_string(), "example.amazonaws.com".to_string()),
777            ("X-Amz-Date".to_string(), "20150830T123600Z".to_string()),
778        ];
779        let s = sign(
780            &ToSign {
781                method: "GET",
782                path: "/",
783                query: &[],
784                headers: &headers,
785                payload_sha256: EMPTY_SHA256,
786            },
787            &creds("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"),
788            "us-east-1",
789            "service",
790            "20150830T123600Z",
791        );
792        assert_eq!(
793            s.canonical_request,
794            "GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
795        );
796        assert_eq!(
797            s.authorization,
798            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
799        );
800    }
801
802    const S3_SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
803
804    fn s3_sign(
805        method: &str,
806        path: &str,
807        query: &[(&str, &str)],
808        headers: &[(&str, &str)],
809        payload: &str,
810    ) -> String {
811        let q: Vec<(String, String)> = query
812            .iter()
813            .map(|(k, v)| (k.to_string(), v.to_string()))
814            .collect();
815        let h: Vec<(String, String)> = headers
816            .iter()
817            .map(|(k, v)| (k.to_string(), v.to_string()))
818            .collect();
819        let s = sign(
820            &ToSign {
821                method,
822                path,
823                query: &q,
824                headers: &h,
825                payload_sha256: payload,
826            },
827            &creds("AKIAIOSFODNN7EXAMPLE", S3_SECRET),
828            "us-east-1",
829            "s3",
830            "20130524T000000Z",
831        );
832        s.authorization
833            .rsplit_once("Signature=")
834            .unwrap()
835            .1
836            .to_string()
837    }
838
839    /// The S3 examples from AWS's "Signature Calculations for the
840    /// Authorization Header" (examplebucket, 2013-05-24).
841    #[test]
842    fn s3_documented_examples() {
843        // GET Object with a Range header.
844        assert_eq!(
845            s3_sign(
846                "GET",
847                "/test.txt",
848                &[],
849                &[
850                    ("Host", "examplebucket.s3.amazonaws.com"),
851                    ("Range", "bytes=0-9"),
852                    ("x-amz-content-sha256", EMPTY_SHA256),
853                    ("x-amz-date", "20130524T000000Z"),
854                ],
855                EMPTY_SHA256,
856            ),
857            "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
858        );
859        // PUT Object.
860        let body = sha256_hex(b"Welcome to Amazon S3.");
861        assert_eq!(
862            body,
863            "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072"
864        );
865        assert_eq!(
866            s3_sign(
867                "PUT",
868                "/test%24file.text",
869                &[],
870                &[
871                    ("Host", "examplebucket.s3.amazonaws.com"),
872                    ("Date", "Fri, 24 May 2013 00:00:00 GMT"),
873                    ("x-amz-date", "20130524T000000Z"),
874                    ("x-amz-storage-class", "REDUCED_REDUNDANCY"),
875                    ("x-amz-content-sha256", &body),
876                ],
877                &body,
878            ),
879            "98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd"
880        );
881        // GET Bucket lifecycle: a query parameter with no value.
882        assert_eq!(
883            s3_sign(
884                "GET",
885                "/",
886                &[("lifecycle", "")],
887                &[
888                    ("Host", "examplebucket.s3.amazonaws.com"),
889                    ("x-amz-date", "20130524T000000Z"),
890                    ("x-amz-content-sha256", EMPTY_SHA256),
891                ],
892                EMPTY_SHA256,
893            ),
894            "fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543"
895        );
896        // GET Bucket (list objects): sorted query parameters.
897        assert_eq!(
898            s3_sign(
899                "GET",
900                "/",
901                &[("prefix", "J"), ("max-keys", "2")],
902                &[
903                    ("Host", "examplebucket.s3.amazonaws.com"),
904                    ("x-amz-date", "20130524T000000Z"),
905                    ("x-amz-content-sha256", EMPTY_SHA256),
906                ],
907                EMPTY_SHA256,
908            ),
909            "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
910        );
911    }
912
913    #[test]
914    fn encoding_and_xml() {
915        assert_eq!(uri_encode("a b/c~d$", true), "a%20b/c~d%24");
916        assert_eq!(uri_encode("a/b", false), "a%2Fb");
917        assert_eq!(uri_encode("é", false), "%C3%A9");
918        let x = "<R><Contents><Key>a&amp;b</Key><Size>3</Size><LastModified>t</LastModified></Contents>\
919                 <Contents><Key>c</Key><Size>10</Size></Contents><IsTruncated>false</IsTruncated></R>";
920        let l = parse_list(x);
921        assert_eq!(l.len(), 2);
922        assert_eq!(l[0].key, "a&b");
923        assert_eq!(l[1].size, 10);
924        assert_eq!(
925            s3_error("<Error><Code>NoSuchBucket</Code><Message>gone</Message></Error>"),
926            "NoSuchBucket: gone"
927        );
928        assert_eq!(
929            complete_body(&[(1, "\"e1\"".into()), (2, "\"e2\"".into())]),
930            "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>&quot;e1&quot;</ETag></Part>\
931             <Part><PartNumber>2</PartNumber><ETag>&quot;e2&quot;</ETag></Part></CompleteMultipartUpload>"
932        );
933        assert!(parse_endpoint("ftp://x").is_err());
934        assert!(parse_endpoint("https://x/path").is_err());
935        assert_eq!(
936            parse_endpoint("https://s3.example.com:443").unwrap().1,
937            "s3.example.com"
938        );
939        assert_eq!(
940            parse_endpoint("http://127.0.0.1:9000/").unwrap().1,
941            "127.0.0.1:9000"
942        );
943        assert!(validate_bucket("ab").is_err());
944        assert!(validate_bucket("My_Bucket").is_err());
945        validate_bucket("isb-backups.eu").unwrap();
946    }
947
948    /// One request as the fake S3 saw it.
949    #[derive(Debug, Clone)]
950    pub(crate) struct Seen {
951        method: String,
952        target: String,
953        headers: Vec<(String, String)>,
954        body: Vec<u8>,
955    }
956
957    /// A loopback S3 that records requests, stores objects, and answers the
958    /// multipart calls. Checks every request's signature with the secret.
959    #[expect(
960        clippy::too_many_lines,
961        clippy::excessive_nesting,
962        reason = "predates the lint ratchet; split it when next changed"
963    )]
964    pub(crate) fn fake_s3(secret: &'static str) -> (String, Arc<Mutex<Vec<Seen>>>) {
965        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
966        let addr = l.local_addr().unwrap();
967        let seen = Arc::new(Mutex::new(Vec::<Seen>::new()));
968        let s2 = seen.clone();
969        type Store = std::collections::BTreeMap<String, Vec<u8>>;
970        type Parts = std::collections::BTreeMap<u32, Vec<u8>>;
971        let state: Arc<Mutex<(Store, Parts)>> = Default::default();
972        std::thread::spawn(move || {
973            for conn in l.incoming() {
974                let Ok(mut conn) = conn else { continue };
975                let (s2, state) = (s2.clone(), state.clone());
976                // One thread per connection: pooled connections stay open.
977                std::thread::spawn(move || {
978                    let mut r = std::io::BufReader::new(conn.try_clone().unwrap());
979                    loop {
980                        let mut line = String::new();
981                        if r.read_line(&mut line).unwrap_or(0) == 0 {
982                            break;
983                        }
984                        let mut it = line.split_whitespace();
985                        let method = it.next().unwrap_or("").to_string();
986                        let target = it.next().unwrap_or("").to_string();
987                        let mut headers = Vec::new();
988                        loop {
989                            let mut h = String::new();
990                            r.read_line(&mut h).unwrap();
991                            let h = h.trim_end();
992                            if h.is_empty() {
993                                break;
994                            }
995                            let (k, v) = h.split_once(':').unwrap();
996                            headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
997                        }
998                        let get =
999                            |k: &str| headers.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
1000                        let len: usize = get("content-length")
1001                            .and_then(|v| v.parse().ok())
1002                            .unwrap_or(0);
1003                        let mut body = vec![0; len];
1004                        r.read_exact(&mut body).unwrap();
1005                        // Verify the signature as S3 would.
1006                        let (path, query) = target.split_once('?').unwrap_or((&target, ""));
1007                        let q: Vec<(String, String)> = query
1008                            .split('&')
1009                            .filter(|s| !s.is_empty())
1010                            .map(|p| {
1011                                let (k, v) = p.split_once('=').unwrap_or((p, ""));
1012                                (pct_decode(k), pct_decode(v))
1013                            })
1014                            .collect();
1015                        let auth = get("authorization").unwrap_or_default();
1016                        let signed: Vec<&str> = auth
1017                            .split("SignedHeaders=")
1018                            .nth(1)
1019                            .unwrap_or("")
1020                            .split(',')
1021                            .next()
1022                            .unwrap()
1023                            .split(';')
1024                            .collect();
1025                        let hs: Vec<(String, String)> = signed
1026                            .iter()
1027                            .map(|k| (k.to_string(), get(k).unwrap_or_default()))
1028                            .collect();
1029                        let date = get("x-amz-date").unwrap_or_default();
1030                        let payload = get("x-amz-content-sha256").unwrap_or_default();
1031                        let ok_sig = payload == sha256_hex(&body)
1032                            && sign(
1033                                &ToSign {
1034                                    method: &method,
1035                                    path,
1036                                    query: &q,
1037                                    headers: &hs,
1038                                    payload_sha256: &payload,
1039                                },
1040                                &Credentials {
1041                                    access_key: "AK".into(),
1042                                    secret_key: secret.into(),
1043                                },
1044                                "us-east-1",
1045                                "s3",
1046                                &date,
1047                            )
1048                            .authorization
1049                                == auth;
1050                        s2.lock().unwrap().push(Seen {
1051                            method: method.clone(),
1052                            target: target.clone(),
1053                            headers: headers.clone(),
1054                            body: body.clone(),
1055                        });
1056                        let key = pct_decode(path.splitn(3, '/').nth(2).unwrap_or(""));
1057                        let mut st = state.lock().unwrap();
1058                        let (objects, parts) = &mut *st;
1059                        let has = |k: &str| q.iter().any(|(n, _)| n == k);
1060                        let qv = |k: &str| {
1061                            q.iter()
1062                                .find(|(n, _)| n == k)
1063                                .map(|(_, v)| v.clone())
1064                                .unwrap_or_default()
1065                        };
1066                        let (status, extra, resp): (u16, String, Vec<u8>) = if !ok_sig {
1067                            (
1068                            403,
1069                            String::new(),
1070                            b"<Error><Code>SignatureDoesNotMatch</Code><Message>no</Message></Error>"
1071                                .to_vec(),
1072                        )
1073                        } else {
1074                            match method.as_str() {
1075                                "POST" if has("uploads") => {
1076                                    parts.clear();
1077                                    (200, String::new(), b"<InitiateMultipartUploadResult><UploadId>up-1</UploadId></InitiateMultipartUploadResult>".to_vec())
1078                                }
1079                                "PUT" if has("partNumber") => {
1080                                    let n: u32 = qv("partNumber").parse().unwrap();
1081                                    parts.insert(n, body.clone());
1082                                    (200, format!("ETag: \"etag-{n}\"\r\n"), vec![])
1083                                }
1084                                "POST" if has("uploadId") => {
1085                                    let all: Vec<u8> = parts.values().flatten().copied().collect();
1086                                    objects.insert(key.clone(), all);
1087                                    (
1088                                        200,
1089                                        String::new(),
1090                                        b"<CompleteMultipartUploadResult/>".to_vec(),
1091                                    )
1092                                }
1093                                "DELETE" if has("uploadId") => (204, String::new(), vec![]),
1094                                "PUT" => {
1095                                    objects.insert(key.clone(), body.clone());
1096                                    (200, String::new(), vec![])
1097                                }
1098                                "HEAD" => match objects.get(&key) {
1099                                    Some(o) => {
1100                                        (200, format!("Content-Length: {}\r\n", o.len()), vec![])
1101                                    }
1102                                    None => (404, String::new(), vec![]),
1103                                },
1104                                "GET" if key.is_empty() => {
1105                                    let mut x = String::from("<ListBucketResult>");
1106                                    for (k, v) in objects.iter() {
1107                                        if k.starts_with(&qv("prefix")) {
1108                                            x.push_str(&format!(
1109                                            "<Contents><Key>{k}</Key><Size>{}</Size></Contents>",
1110                                            v.len()
1111                                        ));
1112                                        }
1113                                    }
1114                                    x.push_str(
1115                                        "<IsTruncated>false</IsTruncated></ListBucketResult>",
1116                                    );
1117                                    (200, String::new(), x.into_bytes())
1118                                }
1119                                "GET" => match objects.get(&key) {
1120                                    Some(o) => (200, String::new(), o.clone()),
1121                                    None => (404, String::new(), vec![]),
1122                                },
1123                                "DELETE" => {
1124                                    objects.remove(&key);
1125                                    (204, String::new(), vec![])
1126                                }
1127                                _ => (400, String::new(), vec![]),
1128                            }
1129                        };
1130                        // HEAD carries the object's length but no body.
1131                        let len_header = if method == "HEAD" {
1132                            String::new()
1133                        } else {
1134                            format!("Content-Length: {}\r\n", resp.len())
1135                        };
1136                        let head = format!("HTTP/1.1 {status} X\r\n{extra}{len_header}\r\n");
1137                        if conn.write_all(head.as_bytes()).is_err() {
1138                            break;
1139                        }
1140                        if method != "HEAD" {
1141                            let _ = conn.write_all(&resp);
1142                        }
1143                    }
1144                });
1145            }
1146        });
1147        (format!("http://{addr}"), seen)
1148    }
1149
1150    fn pct_decode(s: &str) -> String {
1151        let b = s.as_bytes();
1152        let mut out = Vec::new();
1153        let mut i = 0;
1154        while i < b.len() {
1155            if b[i] == b'%' && i + 2 < b.len() {
1156                out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
1157                i += 3;
1158            } else {
1159                out.push(b[i]);
1160                i += 1;
1161            }
1162        }
1163        String::from_utf8(out).unwrap()
1164    }
1165
1166    pub(crate) fn client(endpoint: &str, secret: &str) -> Client {
1167        Client::new(Bucket {
1168            endpoint: endpoint.into(),
1169            region: "us-east-1".into(),
1170            bucket: "backups".into(),
1171            path_style: true,
1172            creds: creds("AK", secret),
1173        })
1174        .unwrap()
1175    }
1176
1177    #[test]
1178    fn multipart_against_a_fake_s3() {
1179        let (ep, seen) = fake_s3("sk");
1180        let c = client(&ep, "sk");
1181        // Small: one PUT.
1182        let mut u = c.upload("db/one.gz", "application/gzip");
1183        u.write_all(b"hello").unwrap();
1184        assert_eq!(u.finish().unwrap(), 5);
1185        assert_eq!(c.head("db/one.gz").unwrap(), Some(5));
1186        {
1187            let s = seen.lock().unwrap();
1188            let put = s.iter().find(|r| r.method == "PUT").unwrap();
1189            assert_eq!(put.target, "/backups/db/one.gz");
1190            assert_eq!(put.body, b"hello");
1191        }
1192        seen.lock().unwrap().clear();
1193        // Larger than a part: create, parts, complete.
1194        let data: Vec<u8> = (0..25u32).map(|i| i as u8).collect();
1195        let mut u = c.upload("db/a b.gz", "application/gzip").part_size(10);
1196        for chunk in data.chunks(7) {
1197            u.write_all(chunk).unwrap();
1198        }
1199        assert_eq!(u.finish().unwrap(), 25);
1200        let s = seen.lock().unwrap().clone();
1201        let calls: Vec<String> = s
1202            .iter()
1203            .map(|r| format!("{} {}", r.method, r.target))
1204            .collect();
1205        assert_eq!(
1206            calls,
1207            [
1208                "POST /backups/db/a%20b.gz?uploads",
1209                "PUT /backups/db/a%20b.gz?partNumber=1&uploadId=up-1",
1210                "PUT /backups/db/a%20b.gz?partNumber=2&uploadId=up-1",
1211                "PUT /backups/db/a%20b.gz?partNumber=3&uploadId=up-1",
1212                "POST /backups/db/a%20b.gz?uploadId=up-1",
1213            ]
1214        );
1215        assert_eq!(s[1].body.len(), 10);
1216        assert_eq!(s[3].body.len(), 5);
1217        assert!(
1218            s[0].headers
1219                .iter()
1220                .any(|(k, v)| k == "content-type" && v == "application/gzip")
1221        );
1222        let complete = String::from_utf8(s[4].body.clone()).unwrap();
1223        assert_eq!(
1224            complete,
1225            complete_body(&[
1226                (1, "\"etag-1\"".into()),
1227                (2, "\"etag-2\"".into()),
1228                (3, "\"etag-3\"".into())
1229            ])
1230        );
1231        // The object reads back whole, and lists.
1232        let (len, mut r) = c.get("db/a b.gz").unwrap();
1233        let mut back = Vec::new();
1234        r.read_to_end(&mut back).unwrap();
1235        assert_eq!((len, back), (25, data));
1236        let keys: Vec<String> = c.list("db/").unwrap().into_iter().map(|o| o.key).collect();
1237        assert_eq!(keys, ["db/a b.gz", "db/one.gz"]);
1238        c.delete("db/one.gz").unwrap();
1239        assert_eq!(c.head("db/one.gz").unwrap(), None);
1240    }
1241
1242    #[test]
1243    fn failures_abort_and_report() {
1244        let (ep, seen) = fake_s3("sk");
1245        // A wrong secret: S3's error code comes back.
1246        let bad = client(&ep, "wrong");
1247        let e = bad.put("k", b"x").unwrap_err().to_string();
1248        assert!(
1249            e.contains("SignatureDoesNotMatch") && e.contains("403"),
1250            "{e}"
1251        );
1252        // A multipart upload dropped unfinished is aborted.
1253        let c = client(&ep, "sk");
1254        seen.lock().unwrap().clear();
1255        {
1256            let mut u = c.upload("big", "application/gzip").part_size(4);
1257            u.write_all(b"12345678").unwrap();
1258        }
1259        let s = seen.lock().unwrap();
1260        assert_eq!(s.last().unwrap().method, "DELETE");
1261        assert!(s.last().unwrap().target.contains("uploadId=up-1"));
1262    }
1263}