1use 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
16pub const DEFAULT_REGION: &str = "us-east-1";
18pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
20pub const DEFAULT_MAX_TRIES: u32 = 3;
22pub const DEFAULT_MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
24pub const DEFAULT_PART_SIZE: u64 = 16 * 1024 * 1024;
26pub const DEFAULT_PARALLEL: usize = 4;
28pub const MIN_PART_SIZE: u64 = 5 * 1024 * 1024;
30pub const MAX_PRESIGN_SECS: u64 = 7 * 24 * 3600;
32
33const TRANSIENT: [u16; 5] = [429, 500, 502, 503, 504];
34
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub struct Object {
38 pub key: String,
40 pub size: u64,
42 pub last_modified: String,
44 pub etag: String,
46 pub content_type: Option<String>,
48 pub metadata: BTreeMap<String, String>,
50}
51
52#[derive(Debug, Clone, Default)]
55pub struct Listing {
56 pub objects: Vec<Object>,
58 pub prefixes: Vec<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Bucket {
65 pub name: String,
67 pub created: String,
69}
70
71#[derive(Debug, Clone)]
73pub struct Transfer {
74 pub bucket: String,
76 pub key: String,
78 pub size: u64,
80 pub etag: String,
82 pub elapsed: Duration,
84}
85
86impl Transfer {
87 pub fn url(&self) -> String {
89 format!("s3://{}/{}", self.bucket, self.key)
90 }
91}
92
93#[derive(Debug, Clone, Default)]
95pub struct PutOptions {
96 pub content_type: Option<String>,
98 pub metadata: Vec<(String, String)>,
100 pub headers: Vec<(String, String)>,
103}
104
105impl PutOptions {
106 pub fn content_type(ct: &str) -> Self {
108 PutOptions {
109 content_type: Some(ct.to_string()),
110 ..Default::default()
111 }
112 }
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct SyncOptions {
118 pub delete: bool,
120 pub last: Option<String>,
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct SyncReport {
127 pub transferred: Vec<String>,
129 pub skipped: Vec<String>,
131 pub deleted: Vec<String>,
133 pub elapsed: Duration,
135}
136
137#[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 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
161 self.endpoint = Some(endpoint.into());
162 self
163 }
164 pub fn region(mut self, region: impl Into<String>) -> Self {
166 self.region = Some(region.into());
167 self
168 }
169 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 pub fn session_token(mut self, token: impl Into<String>) -> Self {
181 self.session_token = Some(token.into());
182 self
183 }
184 pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
186 self.bucket = Some(bucket.into());
187 self
188 }
189 pub fn timeout(mut self, timeout: Duration) -> Self {
191 self.timeout = Some(timeout);
192 self
193 }
194 pub fn max_tries(mut self, tries: u32) -> Self {
196 self.max_tries = Some(tries.max(1));
197 self
198 }
199 pub fn multipart_threshold(mut self, bytes: u64) -> Self {
201 self.multipart_threshold = Some(bytes);
202 self
203 }
204 pub fn part_size(mut self, bytes: u64) -> Self {
206 self.part_size = Some(bytes.max(MIN_PART_SIZE));
207 self
208 }
209 pub fn parallel(mut self, n: usize) -> Self {
211 self.parallel = Some(n.max(1));
212 self
213 }
214
215 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
287fn 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 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#[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
374fn 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#[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
399pub 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
408pub 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 pub fn from_env() -> Result<Client> {
441 ClientBuilder::default().build()
442 }
443
444 pub fn builder() -> ClientBuilder {
446 ClientBuilder::default()
447 }
448
449 pub fn endpoint(&self) -> &str {
451 &self.endpoint
452 }
453
454 pub fn bucket(&self) -> Option<&str> {
456 self.bucket.as_deref()
457 }
458
459 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 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 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 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 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 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 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 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 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 pub fn list(&self, prefix: &str) -> Result<Vec<Object>> {
853 Ok(self.list_with(prefix, None)?.objects)
854 }
855
856 pub fn list_dir(&self, prefix: &str) -> Result<Listing> {
859 self.list_with(prefix, Some("/"))
860 }
861
862 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 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 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 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 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 pub fn exists(&self, key: &str) -> Result<bool> {
1020 Ok(self.head(key)?.is_some())
1021 }
1022
1023 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 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 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 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 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 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
1385fn 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
1394fn 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
1511pub 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
1527pub 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 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 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}