1use std::io::{Read, Write};
12use std::time::Duration;
13
14use ring::{digest, hmac};
15
16use crate::error::{Error, Result};
17
18pub const PART_SIZE: usize = 16 << 20;
21
22const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
23
24#[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
40fn 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
55pub 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
63pub 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
79pub struct ToSign<'a> {
82 pub method: &'a str,
83 pub path: &'a str,
85 pub query: &'a [(String, String)],
86 pub headers: &'a [(String, String)],
87 pub payload_sha256: &'a str,
88}
89
90pub struct Signed {
92 pub canonical_request: String,
93 pub string_to_sign: String,
94 pub authorization: String,
95}
96
97pub 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 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#[derive(Debug, Clone)]
168pub struct Bucket {
169 pub endpoint: String,
171 pub region: String,
172 pub bucket: String,
173 pub path_style: bool,
175 pub creds: Credentials,
176}
177
178#[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: String,
192 base_path: String,
194 agent: ureq::Agent,
195}
196
197fn 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 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
218fn 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
228pub 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
237pub 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("<", "<")
254 .replace(">", ">")
255 .replace(""", "\"")
256 .replace("'", "'")
257 .replace(""", "\"")
258 .replace("'", "'")
259 .replace("&", "&")
260}
261
262fn xml_escape(s: &str) -> String {
263 s.replace('&', "&")
264 .replace('<', "<")
265 .replace('>', ">")
266 .replace('"', """)
267}
268
269pub 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 .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 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 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 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 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 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 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 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 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
637pub 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
653pub 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 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 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 #[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 #[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 #[test]
842 fn s3_documented_examples() {
843 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 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 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 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&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>"e1"</ETag></Part>\
931 <Part><PartNumber>2</PartNumber><ETag>"e2"</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 #[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 #[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 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 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 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 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 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 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 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 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}