1use async_trait::async_trait;
7use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
8use aws_config::{BehaviorVersion, Region};
9use aws_credential_types::Credentials;
10use aws_sdk_s3::config::ResponseChecksumValidation;
11use aws_sdk_s3::Client;
12use tracing::warn;
13
14use super::s3_common::{
15 apply_prefix, is_not_found_code, normalize_prefix, probe_error, strip_listed_key_prefix,
16};
17use super::{
18 range_header, BlobBody, CloudAccessOutcome, CloudAccessState, CloudHome, CloudHomeError,
19 CloudHomeJoinInfo, ExactSlotStorage, ObjectSlot, PartSink, PhysicalObjectLocator,
20 RevokeOutcome, UploadProgress,
21};
22
23fn s3_runtime() -> &'static tokio::runtime::Runtime {
35 static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
36 RT.get_or_init(|| {
37 tokio::runtime::Builder::new_multi_thread()
38 .worker_threads(2)
39 .thread_stack_size(16 * 1024 * 1024) .thread_name("coven-s3")
41 .enable_all() .build()
43 .expect("build coven S3 runtime")
44 })
45}
46
47struct AbortOnDropTask<T> {
48 handle: Option<tokio::task::JoinHandle<T>>,
49}
50
51impl<T> AbortOnDropTask<T> {
52 fn new(handle: tokio::task::JoinHandle<T>) -> Self {
53 Self {
54 handle: Some(handle),
55 }
56 }
57
58 async fn wait(mut self) -> Result<T, tokio::task::JoinError> {
59 let result = self
60 .handle
61 .as_mut()
62 .expect("S3 task handle is present")
63 .await;
64 self.handle.take();
65 result
66 }
67}
68
69impl<T> Drop for AbortOnDropTask<T> {
70 fn drop(&mut self) {
71 if let Some(handle) = self.handle.take() {
72 handle.abort();
73 }
74 }
75}
76
77async fn on_s3_rt<T: Send + 'static>(
81 fut: impl std::future::Future<Output = Result<T, CloudHomeError>> + Send + 'static,
82) -> Result<T, CloudHomeError> {
83 match AbortOnDropTask::new(s3_runtime().spawn(fut)).wait().await {
84 Ok(r) => r,
85 Err(e) => Err(CloudHomeError::Transport(format!("S3 task aborted: {e}"))),
86 }
87}
88
89#[derive(Clone)]
91pub struct S3CloudHome {
92 client: Client,
93 sts_client: Option<aws_sdk_sts::Client>,
94 bucket: String,
95 region: String,
96 endpoint: Option<String>,
97 access_key: String,
98 secret_key: String,
99 key_prefix: Option<String>,
100 exact_slots: bool,
101}
102
103fn create_only_put_failed(
104 error: &aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::put_object::PutObjectError>,
105) -> bool {
106 use aws_sdk_s3::error::ProvideErrorMetadata;
107 matches!(
108 error.code(),
109 Some("PreconditionFailed" | "ConditionalRequestConflict")
110 )
111}
112
113impl S3CloudHome {
114 pub async fn new(
115 bucket: String,
116 region: String,
117 endpoint: Option<String>,
118 access_key: String,
119 secret_key: String,
120 key_prefix: Option<String>,
121 custom_exact_slots: Option<crate::config::CustomS3ExactSlots>,
122 ) -> Result<Self, CloudHomeError> {
123 let exact_slots = endpoint.is_none()
124 || custom_exact_slots
125 == Some(crate::config::CustomS3ExactSlots::StandardConditionalRequests);
126 let credentials =
127 Credentials::new(&access_key, &secret_key, None, None, "coven-cloud-home");
128
129 let http_client = aws_smithy_http_client::Builder::new()
132 .tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
133 aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring,
134 ))
135 .build_https();
136
137 let mut builder = aws_config::defaults(BehaviorVersion::latest())
138 .region(Region::new(region.clone()))
139 .credentials_provider(credentials)
140 .http_client(http_client)
141 .stalled_stream_protection(
150 StalledStreamProtectionConfig::enabled()
151 .grace_period(std::time::Duration::from_secs(60))
152 .build(),
153 );
154
155 if let Some(ref ep) = endpoint {
156 builder = builder.endpoint_url(ep.trim_end_matches('/'));
157 }
158
159 let aws_config = builder.load().await;
160 let s3_config = aws_sdk_s3::config::Builder::from(&aws_config)
161 .force_path_style(true)
162 .response_checksum_validation(ResponseChecksumValidation::WhenRequired)
182 .build();
183 let client = Client::from_conf(s3_config);
184 let sts_client = endpoint
185 .is_none()
186 .then(|| aws_sdk_sts::Client::new(&aws_config));
187
188 Ok(S3CloudHome {
189 client,
190 sts_client,
191 bucket,
192 region,
193 endpoint,
194 access_key,
195 secret_key,
196 key_prefix: normalize_prefix(key_prefix),
199 exact_slots,
200 })
201 }
202
203 fn full_key(&self, key: &str) -> String {
205 apply_prefix(self.key_prefix.as_deref(), key)
206 }
207
208 async fn open_multipart_sink(
209 &self,
210 key: &str,
211 completion: MultipartCompletion,
212 ) -> Result<Box<S3PartSink>, CloudHomeError> {
213 let full = self.full_key(key);
214 let upload_id = {
215 let key = key.to_string();
216 let full = full.clone();
217 let client = self.client.clone();
218 let bucket = self.bucket.clone();
219 on_s3_rt(async move {
220 let create = client
221 .create_multipart_upload()
222 .bucket(&bucket)
223 .key(&full)
224 .send()
225 .await
226 .map_err(|error| {
227 CloudHomeError::Transport(format!("multipart create {key}: {error}"))
228 })?;
229 create
230 .upload_id()
231 .ok_or_else(|| {
232 CloudHomeError::Transport(format!(
233 "multipart create {key}: no upload id returned"
234 ))
235 })
236 .map(str::to_string)
237 })
238 .await?
239 };
240 let (commands, receiver) = tokio::sync::mpsc::channel(1);
241 let owner = S3MultipartOwner {
242 client: self.client.clone(),
243 bucket: self.bucket.clone(),
244 key: full,
245 logical_key: key.to_string(),
246 upload_id,
247 completed: Vec::new(),
248 next_part_number: 1,
249 completion,
250 };
251 Ok(Box::new(S3PartSink {
252 commands: Some(commands),
253 owner: Some(s3_runtime().spawn(owner.run(receiver))),
254 }))
255 }
256
257 async fn put_create_only(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
258 let full = self.full_key(key);
259 let logical_key = key.to_string();
260 let client = self.client.clone();
261 let bucket = self.bucket.clone();
262 on_s3_rt(async move {
263 client
264 .put_object()
265 .bucket(&bucket)
266 .key(&full)
267 .if_none_match("*")
268 .body(data.into())
269 .send()
270 .await
271 .map_err(|error| {
272 if create_only_put_failed(&error) {
273 CloudHomeError::AlreadyExists(logical_key.clone())
274 } else {
275 put_object_error(&logical_key, error)
276 }
277 })?;
278 Ok(())
279 })
280 .await
281 }
282
283 async fn append_create_only(
284 &self,
285 key: &str,
286 mut body: BlobBody,
287 progress: &UploadProgress<'_>,
288 ) -> Result<(), CloudHomeError> {
289 if body.len() <= self.multipart_threshold() {
290 let data = body.collect().await?;
291 let length = data.len() as u64;
292 self.put_create_only(key, data).await?;
293 progress(length);
294 return Ok(());
295 }
296 let mut sink = self
297 .open_multipart_sink(key, MultipartCompletion::CreateOnly)
298 .await?;
299 let total = body.len();
300 let mut offset = 0;
301 loop {
302 let part = match body.next_part(sink.part_size()).await {
303 Ok(Some(part)) => part,
304 Ok(None) if offset == total => break,
305 Ok(None) => {
306 let operation = CloudHomeError::Transport(format!(
307 "append {key}: upload body ended after {offset} of {total} bytes"
308 ));
309 let cleanup = sink.abort().await;
310 return Err(combine_cleanup_failure(operation, cleanup));
311 }
312 Err(operation) => {
313 let cleanup = sink.abort().await;
314 return Err(combine_cleanup_failure(operation, cleanup));
315 }
316 };
317 let length = part.len() as u64;
318 let is_last = offset + length >= total;
319 if let Err(operation) = sink.send_part(part, offset, is_last).await {
320 let cleanup = sink.abort().await;
321 return Err(combine_cleanup_failure(operation, cleanup));
322 }
323 offset += length;
324 progress(offset);
325 }
326 sink.finish().await
327 }
328}
329
330#[derive(Clone, Copy, PartialEq, Eq)]
336enum MultipartCompletion {
337 Mutable,
338 CreateOnly,
339}
340
341struct S3PartSink {
342 commands: Option<tokio::sync::mpsc::Sender<S3MultipartCommand>>,
343 owner: Option<tokio::task::JoinHandle<Result<(), CloudHomeError>>>,
344}
345
346enum S3MultipartCommand {
347 SendPart {
348 part: bytes::Bytes,
349 response: tokio::sync::oneshot::Sender<Result<(), CloudHomeError>>,
350 },
351 Abort,
352 Finish,
353}
354
355struct S3MultipartOwner {
356 client: Client,
357 bucket: String,
358 key: String,
360 logical_key: String,
361 upload_id: String,
362 completed: Vec<aws_sdk_s3::types::CompletedPart>,
363 next_part_number: i32,
364 completion: MultipartCompletion,
365}
366
367impl S3MultipartOwner {
368 async fn run(
369 mut self,
370 mut commands: tokio::sync::mpsc::Receiver<S3MultipartCommand>,
371 ) -> Result<(), CloudHomeError> {
372 while let Some(command) = commands.recv().await {
373 match command {
374 S3MultipartCommand::SendPart { part, response } => {
375 let result = self.send_part(part).await;
376 if response.send(result).is_err() {
377 return self.abort().await;
378 }
379 }
380 S3MultipartCommand::Abort => return self.abort().await,
381 S3MultipartCommand::Finish => return self.finish().await,
382 }
383 }
384 self.abort().await
385 }
386
387 async fn send_part(&mut self, part: bytes::Bytes) -> Result<(), CloudHomeError> {
388 let part_number = self.next_part_number;
389 self.next_part_number += 1;
390 let uploaded = self
391 .client
392 .upload_part()
393 .bucket(&self.bucket)
394 .key(&self.key)
395 .upload_id(&self.upload_id)
396 .part_number(part_number)
397 .body(part.into())
398 .send()
399 .await
400 .map_err(|error| {
401 CloudHomeError::Transport(format!(
402 "multipart part {part_number} {}: {error}",
403 self.key
404 ))
405 })?;
406 self.completed.push(
407 aws_sdk_s3::types::CompletedPart::builder()
408 .part_number(part_number)
409 .set_e_tag(uploaded.e_tag().map(str::to_string))
410 .build(),
411 );
412 Ok(())
413 }
414
415 async fn abort(&mut self) -> Result<(), CloudHomeError> {
416 self.client
417 .abort_multipart_upload()
418 .bucket(&self.bucket)
419 .key(&self.key)
420 .upload_id(&self.upload_id)
421 .send()
422 .await
423 .map_err(|error| {
424 CloudHomeError::Transport(format!("abort multipart {}: {error}", self.key))
425 })?;
426 Ok(())
427 }
428
429 async fn finish(&mut self) -> Result<(), CloudHomeError> {
430 let completed_upload = aws_sdk_s3::types::CompletedMultipartUpload::builder()
431 .set_parts(Some(std::mem::take(&mut self.completed)))
432 .build();
433 let request = self
434 .client
435 .complete_multipart_upload()
436 .bucket(&self.bucket)
437 .key(&self.key)
438 .upload_id(&self.upload_id)
439 .multipart_upload(completed_upload);
440 let request = match self.completion {
441 MultipartCompletion::Mutable => request,
442 MultipartCompletion::CreateOnly => request.if_none_match("*"),
443 };
444 let operation = request.send().await.map(|_| ()).map_err(|error| {
445 use aws_sdk_s3::error::ProvideErrorMetadata;
446 if self.completion == MultipartCompletion::CreateOnly
447 && matches!(
448 error.code(),
449 Some("PreconditionFailed" | "ConditionalRequestConflict")
450 )
451 {
452 CloudHomeError::AlreadyExists(self.logical_key.clone())
453 } else {
454 CloudHomeError::Transport(format!("multipart complete {}: {error}", self.key))
455 }
456 });
457 match operation {
458 Ok(()) => Ok(()),
459 Err(operation) => {
460 let cleanup = self.abort().await;
461 Err(combine_cleanup_failure(operation, cleanup))
462 }
463 }
464 }
465}
466
467impl Drop for S3PartSink {
468 fn drop(&mut self) {
469 self.commands.take();
470 }
471}
472
473impl S3PartSink {
474 async fn settle(&mut self, command: S3MultipartCommand) -> Result<(), CloudHomeError> {
475 let commands = self.commands.take().ok_or_else(|| {
476 CloudHomeError::Transport("S3 multipart upload is already settled".to_string())
477 })?;
478 let send_result = commands.send(command).await;
479 drop(commands);
480 let owner = self
481 .owner
482 .take()
483 .ok_or_else(|| CloudHomeError::Transport("S3 multipart owner is absent".to_string()))?;
484 let result = owner.await.map_err(|error| {
485 CloudHomeError::Transport(format!("S3 multipart owner task failed: {error}"))
486 })?;
487 match (send_result, result) {
488 (Ok(()), result) => result,
489 (Err(_), Err(error)) => Err(error),
490 (Err(_), Ok(())) => Err(CloudHomeError::Transport(
491 "S3 multipart owner stopped before receiving its terminal command".to_string(),
492 )),
493 }
494 }
495}
496
497fn combine_cleanup_failure(
498 operation: CloudHomeError,
499 cleanup: Result<(), CloudHomeError>,
500) -> CloudHomeError {
501 match cleanup {
502 Ok(()) => operation,
503 Err(cleanup) => CloudHomeError::CleanupFailed {
504 operation: Box::new(operation),
505 cleanup: Box::new(cleanup),
506 },
507 }
508}
509
510fn validate_slot(slot: &ObjectSlot) -> Result<(), CloudHomeError> {
511 slot.validate()?;
512 if slot.physical() != &PhysicalObjectLocator::LogicalKey {
513 return Err(CloudHomeError::Transport(format!(
514 "S3 slot for {} must use its logical key as the physical locator",
515 slot.logical_key(),
516 )));
517 }
518 Ok(())
519}
520
521fn s3_access_key_id_hash(access_key_id: &str) -> coven_core::sync::store_commit::ObjectHash {
522 const DOMAIN: &[u8] = b"coven.s3-access-key-id.v1\0";
523 let mut material = Vec::with_capacity(DOMAIN.len() + access_key_id.len());
524 material.extend_from_slice(DOMAIN);
525 material.extend_from_slice(access_key_id.as_bytes());
526 coven_core::sync::store_commit::ObjectHash::digest(&material)
527}
528
529fn custom_s3_origin(endpoint: &str) -> Result<String, CloudHomeError> {
530 coven_core::sync::provider::canonical_custom_s3_origin(endpoint)
531 .map_err(|error| CloudHomeError::Configuration(error.to_string()))
532}
533
534fn aws_caller_identity(
535 account_id: &str,
536 arn: &str,
537 user_id: &str,
538) -> Result<(String, coven_core::sync::storage::AwsPrincipal), CloudHomeError> {
539 use coven_core::sync::storage::AwsPrincipal;
540
541 if account_id.len() != 12 || !account_id.bytes().all(|byte| byte.is_ascii_digit()) {
542 return Err(CloudHomeError::Configuration(
543 "STS GetCallerIdentity returned a malformed AWS account id".to_string(),
544 ));
545 }
546 let fields: Vec<_> = arn.splitn(6, ':').collect();
547 if fields.len() != 6
548 || fields[0] != "arn"
549 || fields[1].is_empty()
550 || !fields[3].is_empty()
551 || fields[4] != account_id
552 {
553 return Err(CloudHomeError::Configuration(
554 "STS GetCallerIdentity returned an unrecognized caller ARN".to_string(),
555 ));
556 }
557 let principal = match (fields[2], fields[5]) {
558 ("iam", "root") if user_id == account_id => AwsPrincipal::Root,
559 ("iam", resource) if resource.starts_with("user/") && !user_id.is_empty() => {
560 AwsPrincipal::User {
561 arn: arn.to_string(),
562 user_id: user_id.to_string(),
563 }
564 }
565 ("sts", resource) if resource.starts_with("assumed-role/") => {
566 let (role_id, session) = user_id.split_once(':').ok_or_else(|| {
567 CloudHomeError::Configuration(
568 "STS assumed-role caller has no stable role-id prefix".to_string(),
569 )
570 })?;
571 if role_id.is_empty() || session.is_empty() {
572 return Err(CloudHomeError::Configuration(
573 "STS assumed-role caller has a malformed user id".to_string(),
574 ));
575 }
576 AwsPrincipal::Role {
577 role_id: role_id.to_string(),
578 }
579 }
580 _ => {
581 return Err(CloudHomeError::Configuration(
582 "STS caller must be the account root, an IAM user, or an assumed role".to_string(),
583 ));
584 }
585 };
586 Ok((fields[1].to_string(), principal))
587}
588
589fn sts_request_error(error: impl std::fmt::Display) -> CloudHomeError {
590 CloudHomeError::Transport(format!("STS GetCallerIdentity failed: {error}"))
591}
592
593#[async_trait]
594impl super::PartSink for S3PartSink {
595 fn part_size(&self) -> usize {
596 MULTIPART_PART_SIZE
597 }
598
599 async fn send_part(
600 &mut self,
601 part: bytes::Bytes,
602 _offset: u64,
603 _is_last: bool,
604 ) -> Result<(), CloudHomeError> {
605 let commands = self.commands.as_ref().ok_or_else(|| {
606 CloudHomeError::Transport("S3 multipart upload is already settled".to_string())
607 })?;
608 let (response, result) = tokio::sync::oneshot::channel();
609 commands
610 .send(S3MultipartCommand::SendPart { part, response })
611 .await
612 .map_err(|_| {
613 CloudHomeError::Transport(
614 "S3 multipart owner stopped before part upload".to_string(),
615 )
616 })?;
617 result.await.map_err(|_| {
618 CloudHomeError::Transport("S3 multipart owner stopped during part upload".to_string())
619 })?
620 }
621
622 async fn abort(&mut self) -> Result<(), CloudHomeError> {
623 if self.commands.is_none() {
624 return Ok(());
625 }
626 self.settle(S3MultipartCommand::Abort).await
627 }
628
629 async fn finish(mut self: Box<Self>) -> Result<(), CloudHomeError> {
630 self.settle(S3MultipartCommand::Finish).await
631 }
632}
633
634const MULTIPART_THRESHOLD: usize = 8 * 1024 * 1024;
638
639const MULTIPART_PART_SIZE: usize = 8 * 1024 * 1024;
643
644fn body_read_error<E>(context: &str, key: &str, err: E) -> CloudHomeError
645where
646 E: std::error::Error + std::fmt::Debug,
647{
648 let mut msg = format!("{context} for {key}: {err}");
649 let mut source = err.source();
650 while let Some(err) = source {
651 msg.push_str(&format!("; caused by: {err}"));
652 source = err.source();
653 }
654 CloudHomeError::Transport(msg)
655}
656
657fn get_object_error<R>(
664 key: &str,
665 err: aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::get_object::GetObjectError, R>,
666) -> CloudHomeError {
667 use aws_sdk_s3::error::ProvideErrorMetadata;
668 match err.code() {
669 Some("NoSuchKey") => CloudHomeError::NotFound(key.to_string()),
670 Some(code) => CloudHomeError::Transport(match err.message() {
671 Some(msg) => format!("get {key}: S3 {code}: {msg}"),
672 None => format!("get {key}: S3 {code} (no message provided)"),
673 }),
674 None => CloudHomeError::Transport(format!("get {key}: {err}")),
677 }
678}
679
680fn put_object_error(
692 key: &str,
693 err: aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::put_object::PutObjectError>,
694) -> CloudHomeError {
695 use aws_sdk_s3::error::ProvideErrorMetadata;
696 match err.code() {
697 Some("AccessDenied") => CloudHomeError::Configuration(
698 "Your S3 credentials don't have permission to write to this bucket. Check the access policy in sync settings."
699 .to_string(),
700 ),
701 Some("NoSuchBucket") => CloudHomeError::Configuration(
702 "The S3 bucket no longer exists. Check the bucket name in sync settings.".to_string(),
703 ),
704 Some("OverQuota" | "QuotaExceeded") => CloudHomeError::Configuration(
705 "Your S3 storage quota is exceeded. Free up space or expand the quota.".to_string(),
706 ),
707 Some(code) => CloudHomeError::Transport(match err.message() {
708 Some(msg) => format!("put {key}: S3 {code}: {msg}"),
709 None => format!("put {key}: S3 {code} (no message provided)"),
710 }),
711 None => CloudHomeError::Transport(format!("put {key}: {err}")),
712 }
713}
714
715impl S3CloudHome {
716 async fn probe(&self) -> Result<(), CloudHomeError> {
718 let client = self.client.clone();
719 let bucket = self.bucket.clone();
720 on_s3_rt(async move {
721 use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
722
723 match client.head_bucket().bucket(&bucket).send().await {
724 Ok(_) => Ok(()),
725 Err(SdkError::ServiceError(svc)) => {
726 let status = svc.raw().status().as_u16();
727 let code: Option<String> = svc.err().code().map(str::to_string);
728 Err(probe_error(status, code.as_deref(), &bucket))
731 }
732 Err(e) => Err(CloudHomeError::Transport(format!("S3 probe failed: {e}"))),
733 }
734 })
735 .await
736 }
737
738 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
739 let full = self.full_key(key);
740 let key = key.to_string();
741 let client = self.client.clone();
742 let bucket = self.bucket.clone();
743 on_s3_rt(async move {
744 client
745 .put_object()
746 .bucket(&bucket)
747 .key(&full)
748 .body(data.into())
749 .send()
750 .await
751 .map_err(|e| put_object_error(&key, e))?;
752 Ok(())
753 })
754 .await
755 }
756
757 async fn open_multipart<'a>(
758 &'a self,
759 key: &str,
760 _total_len: u64,
761 ) -> Result<super::BoxPartSink<'a>, CloudHomeError> {
762 Ok(self
763 .open_multipart_sink(key, MultipartCompletion::Mutable)
764 .await?)
765 }
766
767 fn multipart_threshold(&self) -> u64 {
768 MULTIPART_THRESHOLD as u64
769 }
770
771 async fn create_at_slot(
772 &self,
773 slot: &ObjectSlot,
774 body: BlobBody,
775 progress: &UploadProgress<'_>,
776 ) -> Result<(), CloudHomeError> {
777 validate_slot(slot)?;
778 self.append_create_only(slot.logical_key(), body, progress)
779 .await
780 }
781
782 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
783 let full = self.full_key(key);
784 let key = key.to_string();
785 let client = self.client.clone();
786 let bucket = self.bucket.clone();
787 on_s3_rt(async move {
790 let resp = client
791 .get_object()
792 .bucket(&bucket)
793 .key(&full)
794 .send()
795 .await
796 .map_err(|e| get_object_error(&key, e))?;
797
798 let bytes = resp
799 .body
800 .collect()
801 .await
802 .map_err(|e| body_read_error("read body", &key, e))?
803 .into_bytes()
804 .to_vec();
805
806 Ok(bytes)
807 })
808 .await
809 }
810
811 async fn read_exact_to_file(
812 &self,
813 slot: &ObjectSlot,
814 destination: &std::path::Path,
815 ) -> Result<(), super::CloudFileReadError> {
816 validate_slot(slot)?;
817 let full = self.full_key(slot.logical_key());
818 let key = slot.logical_key().to_string();
819 let client = self.client.clone();
820 let bucket = self.bucket.clone();
821 let destination = destination.to_path_buf();
822 match AbortOnDropTask::new(s3_runtime().spawn(async move {
823 let response = client
824 .get_object()
825 .bucket(&bucket)
826 .key(&full)
827 .send()
828 .await
829 .map_err(|error| get_object_error(&key, error))?;
830 let stream =
831 futures_util::stream::unfold((response.body, key), |(mut body, key)| async move {
832 body.next().await.map(|result| {
833 let result = result
834 .map_err(|error| body_read_error("read appended body", &key, error));
835 (result, (body, key))
836 })
837 });
838 super::write_cloud_object_stream(&destination, Box::pin(stream)).await?;
839 Ok::<(), super::CloudFileReadError>(())
840 }))
841 .wait()
842 .await
843 {
844 Ok(result) => result,
845 Err(error) => Err(super::CloudFileReadError::Source(
846 CloudHomeError::Transport(format!("S3 task aborted: {error}")),
847 )),
848 }
849 }
850
851 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
852 let full = self.full_key(key);
853 let range = range_header(start, end);
854 let key = key.to_string();
855 let client = self.client.clone();
856 let bucket = self.bucket.clone();
857 on_s3_rt(async move {
858 let resp = client
859 .get_object()
860 .bucket(&bucket)
861 .key(&full)
862 .range(range)
863 .send()
864 .await
865 .map_err(|e| get_object_error(&key, e))?;
866
867 let bytes = resp
868 .body
869 .collect()
870 .await
871 .map_err(|e| body_read_error("read range body", &key, e))?
872 .into_bytes()
873 .to_vec();
874
875 let expected = end - start;
882 if bytes.len() as u64 != expected {
883 return Err(CloudHomeError::Transport(format!(
884 "read range {key}: expected {expected} bytes for range {start}..{end}, \
885 got {} — the provider likely ignored Range and returned the whole object",
886 bytes.len()
887 )));
888 }
889
890 Ok(bytes)
891 })
892 .await
893 }
894
895 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
896 let full_prefix = self.full_key(prefix);
897 let key_prefix = self.key_prefix.clone();
898 let prefix = prefix.to_string();
899 let client = self.client.clone();
900 let bucket = self.bucket.clone();
901 on_s3_rt(async move {
904 let mut keys = Vec::new();
905 let mut continuation_token: Option<String> = None;
906
907 loop {
908 let mut req = client
909 .list_objects_v2()
910 .bucket(&bucket)
911 .prefix(&full_prefix);
912
913 if let Some(token) = continuation_token.take() {
914 req = req.continuation_token(token);
915 }
916
917 let resp = req
918 .send()
919 .await
920 .map_err(|e| CloudHomeError::Transport(format!("list {prefix}: {e}")))?;
921
922 for obj in resp.contents() {
923 let Some(key) = obj.key() else {
924 warn!("list {prefix}: S3 returned an object with no key; skipping it");
925 continue;
926 };
927 let Some(stripped) =
928 strip_listed_key_prefix(key_prefix.as_deref(), &full_prefix, key)
929 else {
930 warn!(
931 "list {prefix}: key {key} is outside the configured S3 prefix {:?}; \
932 skipping it",
933 key_prefix
934 );
935 continue;
936 };
937 keys.push(stripped.to_string());
938 }
939
940 if resp.is_truncated() == Some(true) {
941 let token = resp.next_continuation_token().ok_or_else(|| {
942 CloudHomeError::Transport(format!(
943 "list {prefix}: S3 truncated but returned no continuation token"
944 ))
945 })?;
946 continuation_token = Some(token.to_string());
947 } else {
948 break;
949 }
950 }
951
952 Ok(keys)
953 })
954 .await
955 }
956
957 async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
958 let full = self.full_key(key);
959 let key = key.to_string();
960 let client = self.client.clone();
961 let bucket = self.bucket.clone();
962 on_s3_rt(async move {
963 use aws_sdk_s3::error::ProvideErrorMetadata;
964 if let Err(e) = client
965 .delete_object()
966 .bucket(&bucket)
967 .key(&full)
968 .send()
969 .await
970 {
971 if !is_not_found_code(e.code()) {
977 return Err(CloudHomeError::Transport(format!("delete {key}: {e}")));
978 }
979 }
980 Ok(())
981 })
982 .await
983 }
984
985 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
986 let full = self.full_key(key);
987 let key = key.to_string();
988 let client = self.client.clone();
989 let bucket = self.bucket.clone();
990 on_s3_rt(async move {
991 use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
992 match client.head_object().bucket(&bucket).key(&full).send().await {
993 Ok(_) => Ok(true),
994 Err(e) => {
997 let status = match &e {
998 SdkError::ServiceError(svc) => Some(svc.raw().status().as_u16()),
999 _ => None,
1000 };
1001 if is_not_found_code(e.code()) || status == Some(404) {
1002 Ok(false)
1003 } else {
1004 Err(CloudHomeError::Transport(format!("head {key}: {e}")))
1005 }
1006 }
1007 }
1008 })
1009 .await
1010 }
1011
1012 async fn set_access(
1013 &self,
1014 desired: CloudAccessState,
1015 ) -> Result<CloudAccessOutcome, CloudHomeError> {
1016 Ok(match desired {
1017 CloudAccessState::Present { .. } => {
1018 CloudAccessOutcome::Present(CloudHomeJoinInfo::S3 {
1019 bucket: self.bucket.clone(),
1020 region: self.region.clone(),
1021 endpoint: self.endpoint.clone(),
1022 access_key: self.access_key.clone(),
1023 secret_key: self.secret_key.clone(),
1024 key_prefix: self.key_prefix.clone(),
1025 })
1026 }
1027 CloudAccessState::Absent { .. } => {
1028 CloudAccessOutcome::Absent(RevokeOutcome::Unsupported)
1029 }
1030 })
1031 }
1032}
1033
1034#[async_trait]
1035impl CloudHome for S3CloudHome {
1036 fn exact_slot_storage(
1037 self: std::sync::Arc<Self>,
1038 ) -> Option<std::sync::Arc<dyn ExactSlotStorage>> {
1039 self.exact_slots.then_some(self)
1040 }
1041
1042 async fn probe(&self) -> Result<(), CloudHomeError> {
1043 S3CloudHome::probe(self).await
1044 }
1045 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
1046 S3CloudHome::put_object(self, key, data).await
1047 }
1048 async fn open_multipart<'a>(
1049 &'a self,
1050 key: &str,
1051 total_len: u64,
1052 ) -> Result<super::BoxPartSink<'a>, CloudHomeError> {
1053 S3CloudHome::open_multipart(self, key, total_len).await
1054 }
1055 fn multipart_threshold(&self) -> u64 {
1056 S3CloudHome::multipart_threshold(self)
1057 }
1058 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
1059 S3CloudHome::read(self, key).await
1060 }
1061 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
1062 S3CloudHome::read_range(self, key, start, end).await
1063 }
1064 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
1065 S3CloudHome::list(self, prefix).await
1066 }
1067 async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
1068 S3CloudHome::delete(self, key).await
1069 }
1070 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
1071 S3CloudHome::exists(self, key).await
1072 }
1073 async fn set_access(
1074 &self,
1075 desired: CloudAccessState,
1076 ) -> Result<CloudAccessOutcome, CloudHomeError> {
1077 S3CloudHome::set_access(self, desired).await
1078 }
1079}
1080
1081#[async_trait]
1082impl ExactSlotStorage for S3CloudHome {
1083 async fn provider_binding(
1084 &self,
1085 ) -> Result<coven_core::sync::storage::ResolvedProviderBinding, CloudHomeError> {
1086 use coven_core::sync::storage::{
1087 ProviderDeviceBinding, ProviderPrincipalId, ResolvedProviderBinding, S3EndpointBinding,
1088 StoreProviderBinding,
1089 };
1090
1091 if self.bucket.is_empty() || self.region.is_empty() || self.access_key.is_empty() {
1092 return Err(CloudHomeError::Configuration(
1093 "S3 provider binding requires a bucket, region, and access-key id".to_string(),
1094 ));
1095 }
1096 let (endpoint, principal) = match self.endpoint.as_deref() {
1097 None => {
1098 let client = self.sts_client.clone().ok_or_else(|| {
1099 CloudHomeError::Configuration("AWS S3 adapter has no STS client".to_string())
1100 })?;
1101 let identity = on_s3_rt(async move {
1102 client
1103 .get_caller_identity()
1104 .send()
1105 .await
1106 .map_err(sts_request_error)
1107 })
1108 .await?;
1109 let account = identity.account().ok_or_else(|| {
1110 CloudHomeError::Configuration(
1111 "STS GetCallerIdentity returned no account id".to_string(),
1112 )
1113 })?;
1114 let arn = identity.arn().ok_or_else(|| {
1115 CloudHomeError::Configuration(
1116 "STS GetCallerIdentity returned no caller ARN".to_string(),
1117 )
1118 })?;
1119 let user_id = identity.user_id().ok_or_else(|| {
1120 CloudHomeError::Configuration(
1121 "STS GetCallerIdentity returned no user id".to_string(),
1122 )
1123 })?;
1124 let (partition, principal) = aws_caller_identity(account, arn, user_id)?;
1125 (
1126 S3EndpointBinding::Aws { partition },
1127 ProviderPrincipalId::Aws {
1128 account_id: account.to_string(),
1129 principal,
1130 },
1131 )
1132 }
1133 Some(endpoint) => (
1134 S3EndpointBinding::Custom {
1135 origin: custom_s3_origin(endpoint)?,
1136 },
1137 ProviderPrincipalId::CustomS3Credential {
1138 access_key_id_hash: s3_access_key_id_hash(&self.access_key),
1139 },
1140 ),
1141 };
1142 let binding = ResolvedProviderBinding {
1143 store: StoreProviderBinding::S3 {
1144 endpoint,
1145 region: self.region.to_ascii_lowercase(),
1146 bucket: self.bucket.clone(),
1147 key_prefix: self.key_prefix.clone(),
1148 },
1149 device: ProviderDeviceBinding { principal },
1150 };
1151 binding
1152 .validate()
1153 .map_err(|error| CloudHomeError::Configuration(error.to_string()))?;
1154 Ok(binding)
1155 }
1156
1157 async fn allocate_slot(&self, logical_key: &str) -> Result<ObjectSlot, CloudHomeError> {
1158 ObjectSlot::logical(logical_key.to_string())
1159 }
1160
1161 async fn create_at(
1162 &self,
1163 slot: &ObjectSlot,
1164 body: BlobBody,
1165 progress: &UploadProgress<'_>,
1166 ) -> Result<(), CloudHomeError> {
1167 S3CloudHome::create_at_slot(self, slot, body, progress).await
1168 }
1169 async fn read_at(&self, slot: &ObjectSlot) -> Result<Vec<u8>, CloudHomeError> {
1170 validate_slot(slot)?;
1171 S3CloudHome::read(self, slot.logical_key()).await
1172 }
1173 async fn read_range_at(
1174 &self,
1175 slot: &ObjectSlot,
1176 start: u64,
1177 end: u64,
1178 ) -> Result<Vec<u8>, CloudHomeError> {
1179 validate_slot(slot)?;
1180 S3CloudHome::read_range(self, slot.logical_key(), start, end).await
1181 }
1182 async fn read_at_to_file(
1183 &self,
1184 slot: &ObjectSlot,
1185 destination: &std::path::Path,
1186 ) -> Result<(), super::CloudFileReadError> {
1187 S3CloudHome::read_exact_to_file(self, slot, destination).await
1188 }
1189 async fn delete_at(&self, slot: &ObjectSlot) -> Result<(), CloudHomeError> {
1190 validate_slot(slot)?;
1191 S3CloudHome::delete(self, slot.logical_key()).await
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198 use axum::body::Body;
1199 use axum::extract::State;
1200 use axum::http::header::{CONTENT_LENGTH, CONTENT_RANGE, IF_NONE_MATCH, RANGE};
1201 use axum::http::{HeaderMap, Method, Response, StatusCode, Uri};
1202 use axum::Router;
1203 use bytes::Bytes;
1204 use std::sync::atomic::{AtomicUsize, Ordering};
1205 use std::sync::Arc;
1206
1207 struct FailingBodyReader {
1208 emitted: bool,
1209 }
1210
1211 #[async_trait]
1212 impl crate::local_blob::PlaintextChunkReader for FailingBodyReader {
1213 async fn next_chunk(
1214 &mut self,
1215 _max: usize,
1216 ) -> Result<Vec<u8>, crate::local_blob::PlaintextChunkError> {
1217 if !self.emitted {
1218 self.emitted = true;
1219 return Ok(vec![7; MULTIPART_PART_SIZE]);
1220 }
1221 Err(crate::local_blob::PlaintextChunkError::Local(
1222 "injected body failure".to_string(),
1223 ))
1224 }
1225 }
1226
1227 #[test]
1228 fn full_key_prepends_prefix() {
1229 let key = apply_prefix(Some("libs/abc"), "objects/dev1.json");
1230 assert_eq!(key, "libs/abc/objects/dev1.json");
1231 }
1232
1233 #[test]
1234 fn full_key_no_prefix() {
1235 let key = apply_prefix(None, "objects/dev1.json");
1236 assert_eq!(key, "objects/dev1.json");
1237 }
1238
1239 #[test]
1240 fn normalized_prefix_drops_trailing_slash() {
1241 let prefix = normalize_prefix(Some("libs/abc/".to_string()));
1242 let key = apply_prefix(prefix.as_deref(), "objects/dev1.json");
1243 assert_eq!(key, "libs/abc/objects/dev1.json");
1244 }
1245
1246 #[derive(Clone)]
1247 struct FakeRangeObject {
1248 bucket: String,
1249 key: String,
1250 range_body: Vec<u8>,
1251 object_len: u64,
1252 whole_object_crc32c: &'static str,
1253 }
1254
1255 async fn fake_s3_range_endpoint(
1256 State(object): State<Arc<FakeRangeObject>>,
1257 method: Method,
1258 uri: Uri,
1259 headers: HeaderMap,
1260 ) -> Response<Body> {
1261 let expected_path = format!("/{}/{}", object.bucket, object.key);
1262 let range = headers.get(RANGE).and_then(|v| v.to_str().ok());
1263
1264 if method != Method::GET || uri.path() != expected_path || range != Some("bytes=0-23") {
1265 return Response::builder()
1266 .status(StatusCode::BAD_REQUEST)
1267 .body(Body::from(format!(
1268 "unexpected request: method={method}, path={}, range={range:?}",
1269 uri.path()
1270 )))
1271 .expect("build bad-request response");
1272 }
1273
1274 Response::builder()
1275 .status(StatusCode::PARTIAL_CONTENT)
1276 .header(CONTENT_RANGE, format!("bytes 0-23/{}", object.object_len))
1277 .header(CONTENT_LENGTH, object.range_body.len().to_string())
1278 .header("x-amz-checksum-crc32c", object.whole_object_crc32c)
1279 .body(Body::from(object.range_body.clone()))
1280 .expect("build fake range response")
1281 }
1282
1283 async fn spawn_fake_s3(app: Router) -> (String, tokio::sync::oneshot::Sender<()>) {
1286 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1287 .await
1288 .expect("bind fake S3 endpoint");
1289 let endpoint = format!("http://{}", listener.local_addr().expect("local addr"));
1290 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1291 tokio::spawn(async move {
1292 axum::serve(
1293 listener,
1294 app.layer(axum::extract::DefaultBodyLimit::disable()),
1295 )
1296 .with_graceful_shutdown(async {
1297 shutdown_rx.await.expect("receive fake S3 shutdown");
1298 })
1299 .await
1300 .expect("fake S3 endpoint failed");
1301 });
1302 (endpoint, shutdown_tx)
1303 }
1304
1305 async fn spawn_fake_s3_endpoint(
1306 object: FakeRangeObject,
1307 ) -> (String, tokio::sync::oneshot::Sender<()>) {
1308 spawn_fake_s3(
1309 Router::new()
1310 .fallback(fake_s3_range_endpoint)
1311 .with_state(Arc::new(object)),
1312 )
1313 .await
1314 }
1315
1316 #[derive(Clone)]
1317 struct FakeFullBodyObject {
1318 bucket: String,
1319 key: String,
1320 full_body: Vec<u8>,
1321 }
1322
1323 async fn fake_s3_full_body_endpoint(
1327 State(object): State<Arc<FakeFullBodyObject>>,
1328 method: Method,
1329 uri: Uri,
1330 ) -> Response<Body> {
1331 let expected_path = format!("/{}/{}", object.bucket, object.key);
1332 if method != Method::GET || uri.path() != expected_path {
1333 return Response::builder()
1334 .status(StatusCode::BAD_REQUEST)
1335 .body(Body::from(format!(
1336 "unexpected request: method={method}, path={}",
1337 uri.path()
1338 )))
1339 .expect("build bad-request response");
1340 }
1341
1342 Response::builder()
1343 .status(StatusCode::OK)
1344 .header(CONTENT_LENGTH, object.full_body.len().to_string())
1345 .body(Body::from(object.full_body.clone()))
1346 .expect("build full-body response")
1347 }
1348
1349 async fn spawn_fake_s3_full_body_endpoint(
1350 object: FakeFullBodyObject,
1351 ) -> (String, tokio::sync::oneshot::Sender<()>) {
1352 spawn_fake_s3(
1353 Router::new()
1354 .fallback(fake_s3_full_body_endpoint)
1355 .with_state(Arc::new(object)),
1356 )
1357 .await
1358 }
1359
1360 #[derive(Clone)]
1361 struct FakePausedBodyObject {
1362 bucket: String,
1363 key: String,
1364 first: Vec<u8>,
1365 second: Vec<u8>,
1366 first_sent: Arc<tokio::sync::Notify>,
1367 release_second: Arc<tokio::sync::Notify>,
1368 }
1369
1370 async fn fake_s3_paused_body_endpoint(
1371 State(object): State<FakePausedBodyObject>,
1372 method: Method,
1373 uri: Uri,
1374 ) -> Response<Body> {
1375 let expected_path = format!("/{}/{}", object.bucket, object.key);
1376 if method != Method::GET || uri.path() != expected_path {
1377 return Response::builder()
1378 .status(StatusCode::BAD_REQUEST)
1379 .body(Body::from("unexpected paused-body request"))
1380 .expect("build bad-request response");
1381 }
1382
1383 let total_len = object.first.len() + object.second.len();
1384 let stream = futures_util::stream::unfold((0u8, object), |(stage, object)| async move {
1385 match stage {
1386 0 => {
1387 object.first_sent.notify_one();
1388 let first = object.first.clone();
1389 Some((Ok::<Bytes, std::io::Error>(Bytes::from(first)), (1, object)))
1390 }
1391 1 => {
1392 object.release_second.notified().await;
1393 let second = object.second.clone();
1394 Some((Ok(Bytes::from(second)), (2, object)))
1395 }
1396 _ => None,
1397 }
1398 });
1399 Response::builder()
1400 .status(StatusCode::OK)
1401 .header(CONTENT_LENGTH, total_len.to_string())
1402 .body(Body::from_stream(stream))
1403 .expect("build paused-body response")
1404 }
1405
1406 async fn spawn_fake_s3_paused_body_endpoint(
1407 object: FakePausedBodyObject,
1408 ) -> (String, tokio::sync::oneshot::Sender<()>) {
1409 spawn_fake_s3(
1410 Router::new()
1411 .fallback(fake_s3_paused_body_endpoint)
1412 .with_state(object),
1413 )
1414 .await
1415 }
1416
1417 async fn atomic_temp_paths(directory: &std::path::Path) -> Vec<std::path::PathBuf> {
1418 let mut entries = tokio::fs::read_dir(directory)
1419 .await
1420 .expect("read destination directory");
1421 let mut temps = Vec::new();
1422 while let Some(entry) = entries.next_entry().await.expect("read destination entry") {
1423 if entry
1424 .file_name()
1425 .to_string_lossy()
1426 .starts_with(coven_core::local_blob::TEMP_BLOB_PREFIX)
1427 {
1428 temps.push(entry.path());
1429 }
1430 }
1431 temps
1432 }
1433
1434 #[derive(Clone)]
1435 struct FakeListState {
1436 bucket: String,
1437 request_count: Arc<AtomicUsize>,
1438 }
1439
1440 async fn fake_s3_truncated_list_endpoint(
1441 State(state): State<FakeListState>,
1442 method: Method,
1443 uri: Uri,
1444 ) -> Response<Body> {
1445 state.request_count.fetch_add(1, Ordering::SeqCst);
1446
1447 let expected_path = format!("/{}/", state.bucket);
1448 if method != Method::GET || uri.path() != expected_path {
1449 return Response::builder()
1450 .status(StatusCode::BAD_REQUEST)
1451 .body(Body::from(format!(
1452 "unexpected request: method={method}, path={}, query={:?}",
1453 uri.path(),
1454 uri.query()
1455 )))
1456 .expect("build bad-request response");
1457 }
1458
1459 Response::builder()
1460 .status(StatusCode::OK)
1461 .header("content-type", "application/xml")
1462 .body(Body::from(
1463 r#"<?xml version="1.0" encoding="UTF-8"?>
1464<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1465 <Name>coven-s3-list-test</Name>
1466 <Prefix>objects/</Prefix>
1467 <KeyCount>1</KeyCount>
1468 <IsTruncated>true</IsTruncated>
1469 <Contents>
1470 <Key>objects/dev1.json</Key>
1471 <Size>10</Size>
1472 </Contents>
1473</ListBucketResult>"#,
1474 ))
1475 .expect("build fake list response")
1476 }
1477
1478 async fn spawn_fake_s3_truncated_list_endpoint(
1479 bucket: String,
1480 ) -> (String, tokio::sync::oneshot::Sender<()>, Arc<AtomicUsize>) {
1481 let request_count = Arc::new(AtomicUsize::new(0));
1482 let state = FakeListState {
1483 bucket,
1484 request_count: request_count.clone(),
1485 };
1486 let (endpoint, shutdown_tx) = spawn_fake_s3(
1487 Router::new()
1488 .fallback(fake_s3_truncated_list_endpoint)
1489 .with_state(state),
1490 )
1491 .await;
1492 (endpoint, shutdown_tx, request_count)
1493 }
1494
1495 async fn fake_s3_two_page_list_endpoint(
1496 State(state): State<FakeListState>,
1497 method: Method,
1498 uri: Uri,
1499 ) -> Response<Body> {
1500 let request = state.request_count.fetch_add(1, Ordering::SeqCst);
1501 if method != Method::GET || uri.path() != format!("/{}/", state.bucket) {
1502 return Response::builder()
1503 .status(StatusCode::BAD_REQUEST)
1504 .body(Body::from("unexpected list request"))
1505 .expect("build response");
1506 }
1507 let (key, truncated, token) = match request {
1508 0 => (
1509 "objects/copy-a",
1510 true,
1511 "<NextContinuationToken>page-2</NextContinuationToken>",
1512 ),
1513 1 if uri
1514 .query()
1515 .is_some_and(|query| query.contains("continuation-token=page-2")) =>
1516 {
1517 ("objects/copy-b", false, "")
1518 }
1519 _ => {
1520 return Response::builder()
1521 .status(StatusCode::BAD_REQUEST)
1522 .body(Body::from(format!("unexpected list page: {uri}")))
1523 .expect("build response");
1524 }
1525 };
1526 Response::builder()
1527 .status(StatusCode::OK)
1528 .header("content-type", "application/xml")
1529 .body(Body::from(format!(
1530 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1531 <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1532 <Name>{}</Name><Prefix>objects/</Prefix><KeyCount>1</KeyCount>\
1533 <IsTruncated>{truncated}</IsTruncated>{token}\
1534 <Contents><Key>{key}</Key><Size>10</Size></Contents>\
1535 </ListBucketResult>",
1536 state.bucket
1537 )))
1538 .expect("build response")
1539 }
1540
1541 #[tokio::test]
1542 async fn listing_exhausts_every_page() {
1543 let requests = Arc::new(AtomicUsize::new(0));
1544 let bucket = "immutable-list-test".to_string();
1545 let (endpoint, shutdown) = spawn_fake_s3(
1546 Router::new()
1547 .fallback(fake_s3_two_page_list_endpoint)
1548 .with_state(FakeListState {
1549 bucket: bucket.clone(),
1550 request_count: requests.clone(),
1551 }),
1552 )
1553 .await;
1554 let home = S3CloudHome::new(
1555 bucket,
1556 "us-east-1".to_string(),
1557 Some(endpoint),
1558 "access-key".to_string(),
1559 "secret-key".to_string(),
1560 None,
1561 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
1562 )
1563 .await
1564 .expect("construct home");
1565
1566 let listing = home.list("objects/").await.expect("list objects");
1567
1568 assert_eq!(requests.load(Ordering::SeqCst), 2);
1569 assert_eq!(
1570 listing,
1571 vec!["objects/copy-a".to_string(), "objects/copy-b".to_string()]
1572 );
1573 shutdown.send(()).expect("shut down fake S3");
1574 }
1575
1576 #[derive(Clone)]
1577 struct FakeWriteState {
1578 bucket: String,
1579 conditional_headers: Arc<std::sync::Mutex<Vec<Option<String>>>>,
1580 }
1581
1582 async fn fake_s3_write_endpoint(
1583 State(state): State<FakeWriteState>,
1584 method: Method,
1585 uri: Uri,
1586 headers: HeaderMap,
1587 ) -> Response<Body> {
1588 if method != Method::PUT || !uri.path().starts_with(&format!("/{}/", state.bucket)) {
1589 return Response::builder()
1590 .status(StatusCode::BAD_REQUEST)
1591 .body(Body::from("unexpected write request"))
1592 .expect("build response");
1593 }
1594 state
1595 .conditional_headers
1596 .lock()
1597 .expect("lock headers")
1598 .push(
1599 headers
1600 .get(IF_NONE_MATCH)
1601 .map(|value| value.to_str().expect("If-None-Match header is UTF-8"))
1602 .map(str::to_string),
1603 );
1604 Response::builder()
1605 .status(StatusCode::OK)
1606 .header("etag", "\"write-etag\"")
1607 .body(Body::empty())
1608 .expect("build response")
1609 }
1610
1611 #[tokio::test]
1612 async fn immutable_append_is_create_only_but_generic_put_remains_mutable() {
1613 let headers = Arc::new(std::sync::Mutex::new(Vec::new()));
1614 let bucket = "immutable-write-test".to_string();
1615 let (endpoint, shutdown) =
1616 spawn_fake_s3(Router::new().fallback(fake_s3_write_endpoint).with_state(
1617 FakeWriteState {
1618 bucket: bucket.clone(),
1619 conditional_headers: headers.clone(),
1620 },
1621 ))
1622 .await;
1623 let home = S3CloudHome::new(
1624 bucket,
1625 "us-east-1".to_string(),
1626 Some(endpoint),
1627 "access-key".to_string(),
1628 "secret-key".to_string(),
1629 None,
1630 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
1631 )
1632 .await
1633 .expect("construct home");
1634
1635 home.put_object("mutable", b"first".to_vec())
1636 .await
1637 .expect("generic mutable put");
1638 let slot = ObjectSlot::logical("immutable/copy".to_string()).unwrap();
1639 ExactSlotStorage::create_at(
1640 &home,
1641 &slot,
1642 crate::storage::cloud::BlobBody::from_bytes(b"second".to_vec()),
1643 &crate::storage::cloud::no_progress(),
1644 )
1645 .await
1646 .expect("immutable append");
1647
1648 assert_eq!(
1649 *headers.lock().expect("lock headers"),
1650 vec![None, Some("*".to_string())]
1651 );
1652 assert!(home.exact_slots);
1653 shutdown.send(()).expect("shut down fake S3");
1654 }
1655
1656 #[derive(Clone)]
1657 struct FakeMultipartState {
1658 bucket: String,
1659 completion_headers: Arc<std::sync::Mutex<Vec<Option<String>>>>,
1660 next_upload: Arc<AtomicUsize>,
1661 uploaded_parts: Arc<std::sync::Mutex<Vec<(String, usize)>>>,
1662 aborted_uploads: Arc<std::sync::Mutex<Vec<String>>>,
1663 }
1664
1665 async fn fake_s3_multipart_endpoint(
1666 State(state): State<FakeMultipartState>,
1667 method: Method,
1668 uri: Uri,
1669 headers: HeaderMap,
1670 body: Bytes,
1671 ) -> Response<Body> {
1672 let path_ok = uri.path().starts_with(&format!("/{}/", state.bucket));
1673 if method == Method::POST && path_ok && uri.query().is_some_and(|query| query == "uploads")
1674 {
1675 let upload_number = state.next_upload.fetch_add(1, Ordering::SeqCst) + 1;
1676 return Response::builder()
1677 .status(StatusCode::OK)
1678 .header("content-type", "application/xml")
1679 .body(Body::from(format!(
1680 "<InitiateMultipartUploadResult><Bucket>{}</Bucket><Key>object</Key><UploadId>upload-{upload_number}</UploadId></InitiateMultipartUploadResult>", state.bucket
1681 )))
1682 .expect("build create response");
1683 }
1684 if method == Method::PUT && path_ok {
1685 let query = uri.query().expect("multipart part query");
1686 let upload_id = query
1687 .split('&')
1688 .find_map(|part| part.strip_prefix("uploadId="))
1689 .expect("multipart part uploadId")
1690 .to_string();
1691 let declared_length: usize = headers
1692 .get(CONTENT_LENGTH)
1693 .expect("multipart part Content-Length")
1694 .to_str()
1695 .expect("multipart part Content-Length is UTF-8")
1696 .parse()
1697 .expect("multipart part Content-Length is an integer");
1698 assert_eq!(declared_length, body.len());
1699 state
1700 .uploaded_parts
1701 .lock()
1702 .expect("lock parts")
1703 .push((upload_id, body.len()));
1704 return Response::builder()
1705 .status(StatusCode::OK)
1706 .header("etag", "part-etag")
1707 .body(Body::empty())
1708 .expect("build part response");
1709 }
1710 if method == Method::POST
1711 && path_ok
1712 && uri
1713 .query()
1714 .is_some_and(|query| query.contains("uploadId=upload-"))
1715 {
1716 state.completion_headers.lock().expect("lock headers").push(
1717 headers
1718 .get(IF_NONE_MATCH)
1719 .map(|value| value.to_str().expect("If-None-Match header is UTF-8"))
1720 .map(str::to_string),
1721 );
1722 let collision = uri
1723 .query()
1724 .is_some_and(|query| query.contains("uploadId=upload-2"));
1725 return Response::builder()
1726 .status(if collision {
1727 StatusCode::PRECONDITION_FAILED
1728 } else {
1729 StatusCode::OK
1730 })
1731 .header("content-type", "application/xml")
1732 .body(Body::from(if collision {
1733 "<Error><Code>PreconditionFailed</Code><Message>exists</Message></Error>"
1734 } else {
1735 "<CompleteMultipartUploadResult><ETag>\"etag\"</ETag></CompleteMultipartUploadResult>"
1736 }))
1737 .expect("build complete response");
1738 }
1739 if method == Method::DELETE && path_ok {
1740 let upload_id = uri
1741 .query()
1742 .expect("multipart abort query")
1743 .split('&')
1744 .find_map(|part| part.strip_prefix("uploadId="))
1745 .expect("multipart abort uploadId")
1746 .to_string();
1747 state
1748 .aborted_uploads
1749 .lock()
1750 .expect("lock aborts")
1751 .push(upload_id);
1752 return Response::builder()
1753 .status(StatusCode::NO_CONTENT)
1754 .body(Body::empty())
1755 .expect("build abort response");
1756 }
1757 Response::builder()
1758 .status(StatusCode::BAD_REQUEST)
1759 .body(Body::from(format!(
1760 "unexpected multipart request: {method} {uri}"
1761 )))
1762 .expect("build response")
1763 }
1764
1765 #[tokio::test]
1766 async fn public_immutable_append_streams_parts_and_completes_create_only() {
1767 let headers = Arc::new(std::sync::Mutex::new(Vec::new()));
1768 let parts = Arc::new(std::sync::Mutex::new(Vec::new()));
1769 let aborts = Arc::new(std::sync::Mutex::new(Vec::new()));
1770 let bucket = "immutable-multipart-test".to_string();
1771 let (endpoint, shutdown) = spawn_fake_s3(
1772 Router::new()
1773 .fallback(fake_s3_multipart_endpoint)
1774 .with_state(FakeMultipartState {
1775 bucket: bucket.clone(),
1776 completion_headers: headers.clone(),
1777 next_upload: Arc::new(AtomicUsize::new(0)),
1778 uploaded_parts: parts.clone(),
1779 aborted_uploads: aborts.clone(),
1780 }),
1781 )
1782 .await;
1783 let home = S3CloudHome::new(
1784 bucket,
1785 "us-east-1".to_string(),
1786 Some(endpoint),
1787 "access-key".to_string(),
1788 "secret-key".to_string(),
1789 None,
1790 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
1791 )
1792 .await
1793 .expect("construct home");
1794
1795 let slot = ObjectSlot::logical("immutable".to_string()).unwrap();
1796 ExactSlotStorage::create_at(
1797 &home,
1798 &slot,
1799 BlobBody::from_bytes(vec![9; MULTIPART_THRESHOLD + 1]),
1800 &super::super::no_progress(),
1801 )
1802 .await
1803 .expect("append immutable multipart object");
1804 let collision = ExactSlotStorage::create_at(
1805 &home,
1806 &slot,
1807 BlobBody::from_bytes(vec![8; MULTIPART_THRESHOLD + 1]),
1808 &super::super::no_progress(),
1809 )
1810 .await
1811 .expect_err("second immutable append must collide");
1812
1813 assert!(
1814 matches!(collision, CloudHomeError::AlreadyExists(_)),
1815 "{collision}"
1816 );
1817 assert_eq!(
1818 *headers.lock().expect("lock headers"),
1819 vec![Some("*".to_string()), Some("*".to_string())]
1820 );
1821 let parts = parts.lock().expect("lock parts");
1822 for upload_id in ["upload-1", "upload-2"] {
1823 let lengths: Vec<_> = parts
1824 .iter()
1825 .filter_map(|(id, length)| (id == upload_id).then_some(*length))
1826 .collect();
1827 assert!(lengths.contains(&MULTIPART_PART_SIZE), "{parts:?}");
1828 assert!(lengths.contains(&1), "{parts:?}");
1829 assert!(
1830 lengths
1831 .iter()
1832 .all(|length| matches!(*length, MULTIPART_PART_SIZE | 1)),
1833 "{parts:?}"
1834 );
1835 }
1836 assert_eq!(
1837 *aborts.lock().expect("lock aborts"),
1838 vec!["upload-2".to_string()]
1839 );
1840 shutdown.send(()).expect("shut down fake S3");
1841 }
1842
1843 async fn fake_s3_body_failure_endpoint(
1844 State((bucket, remaining_abort_failures)): State<(String, Arc<AtomicUsize>)>,
1845 method: Method,
1846 uri: Uri,
1847 _body: Bytes,
1848 ) -> Response<Body> {
1849 let path_ok = uri.path().starts_with(&format!("/{bucket}/"));
1850 if method == Method::POST && path_ok && uri.query() == Some("uploads") {
1851 return Response::builder()
1852 .status(StatusCode::OK)
1853 .header("content-type", "application/xml")
1854 .body(Body::from(format!(
1855 "<InitiateMultipartUploadResult><Bucket>{bucket}</Bucket><Key>object</Key><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>"
1856 )))
1857 .expect("build create response");
1858 }
1859 if method == Method::PUT && path_ok {
1860 return Response::builder()
1861 .status(StatusCode::OK)
1862 .header("etag", "part-1")
1863 .body(Body::empty())
1864 .expect("build part response");
1865 }
1866 if method == Method::DELETE && path_ok {
1867 if remaining_abort_failures
1868 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
1869 remaining.checked_sub(1)
1870 })
1871 .is_ok()
1872 {
1873 return Response::builder()
1874 .status(StatusCode::BAD_REQUEST)
1875 .body(Body::from("injected abort failure"))
1876 .expect("build abort failure response");
1877 }
1878 return Response::builder()
1879 .status(StatusCode::NO_CONTENT)
1880 .body(Body::empty())
1881 .expect("build abort response");
1882 }
1883 Response::builder()
1884 .status(StatusCode::BAD_REQUEST)
1885 .body(Body::from(format!("unexpected request: {method} {uri}")))
1886 .expect("build unexpected response")
1887 }
1888
1889 async fn fake_s3_cancel_success_endpoint(
1890 State((bucket, abort_seen)): State<(String, Arc<std::sync::atomic::AtomicBool>)>,
1891 method: Method,
1892 uri: Uri,
1893 ) -> Response<Body> {
1894 let path_ok = uri.path().starts_with(&format!("/{bucket}/"));
1895 if method == Method::POST && path_ok && uri.query() == Some("uploads") {
1896 return Response::builder()
1897 .status(StatusCode::OK)
1898 .header("content-type", "application/xml")
1899 .body(Body::from(format!(
1900 "<InitiateMultipartUploadResult><Bucket>{bucket}</Bucket><Key>object</Key><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>"
1901 )))
1902 .expect("build create response");
1903 }
1904 if method == Method::DELETE && path_ok {
1905 abort_seen.store(true, Ordering::SeqCst);
1906 return Response::builder()
1907 .status(StatusCode::NO_CONTENT)
1908 .body(Body::empty())
1909 .expect("build abort response");
1910 }
1911 Response::builder()
1912 .status(StatusCode::BAD_REQUEST)
1913 .body(Body::from(format!("unexpected request: {method} {uri}")))
1914 .expect("build unexpected response")
1915 }
1916
1917 #[tokio::test]
1918 async fn dropping_a_multipart_sink_starts_abort_without_blocking() {
1919 let bucket = "immutable-cancel-success-test".to_string();
1920 let abort_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
1921 let (endpoint, shutdown) = spawn_fake_s3(
1922 Router::new()
1923 .fallback(fake_s3_cancel_success_endpoint)
1924 .with_state((bucket.clone(), abort_seen.clone())),
1925 )
1926 .await;
1927 let home = S3CloudHome::new(
1928 bucket,
1929 "us-east-1".to_string(),
1930 Some(endpoint),
1931 "access-key".to_string(),
1932 "secret-key".to_string(),
1933 None,
1934 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
1935 )
1936 .await
1937 .unwrap();
1938 let sink = home
1939 .open_multipart_sink("immutable/cancelled", MultipartCompletion::CreateOnly)
1940 .await
1941 .unwrap();
1942
1943 drop(sink);
1944 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1945 while !abort_seen.load(Ordering::SeqCst) {
1946 tokio::task::yield_now().await;
1947 }
1948 })
1949 .await
1950 .expect("multipart owner must abort after its command channel closes");
1951 shutdown.send(()).expect("shut down fake S3");
1952 }
1953
1954 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1955 async fn immutable_append_reports_body_and_multipart_abort_failures() {
1956 let bucket = "immutable-body-failure-test".to_string();
1957 let abort_failures = Arc::new(AtomicUsize::new(1));
1958 let (endpoint, shutdown) = spawn_fake_s3(
1959 Router::new()
1960 .fallback(fake_s3_body_failure_endpoint)
1961 .with_state((bucket.clone(), abort_failures.clone())),
1962 )
1963 .await;
1964 let home = S3CloudHome::new(
1965 bucket,
1966 "us-east-1".to_string(),
1967 Some(endpoint),
1968 "access-key".to_string(),
1969 "secret-key".to_string(),
1970 None,
1971 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
1972 )
1973 .await
1974 .expect("construct home");
1975 let reader = crate::local_blob::PlaintextReader::from_test_reader(FailingBodyReader {
1976 emitted: false,
1977 });
1978 let body = BlobBody::from_test_reader((MULTIPART_PART_SIZE + 1) as u64, reader);
1979
1980 let slot = ObjectSlot::logical("immutable/body-failure".to_string()).unwrap();
1981 let error = ExactSlotStorage::create_at(&home, &slot, body, &super::super::no_progress())
1982 .await
1983 .expect_err("body failure must abort synchronously");
1984
1985 assert!(
1986 matches!(error, CloudHomeError::CleanupFailed { .. }),
1987 "{error}"
1988 );
1989 assert!(
1990 error.to_string().contains("injected body failure"),
1991 "{error}"
1992 );
1993 assert!(error.to_string().contains("abort multipart"), "{error}");
1994 assert_eq!(abort_failures.load(Ordering::SeqCst), 0);
1995 shutdown.send(()).expect("shut down fake S3");
1996 }
1997
1998 #[tokio::test]
1999 async fn exact_operations_reject_an_opaque_s3_locator() {
2000 let home = S3CloudHome::new(
2001 "exact-locator-test".to_string(),
2002 "us-east-1".to_string(),
2003 Some("http://127.0.0.1:9".to_string()),
2004 "access-key".to_string(),
2005 "secret-key".to_string(),
2006 None,
2007 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
2008 )
2009 .await
2010 .expect("construct home");
2011 let slot = ObjectSlot::opaque("protocol/copy".to_string(), "protocol/other".to_string())
2012 .expect("build opaque S3 locator");
2013
2014 let read_error = ExactSlotStorage::read_at(&home, &slot)
2015 .await
2016 .expect_err("opaque S3 read must fail");
2017 assert!(read_error.to_string().contains("must use its logical key"));
2018 let destination = std::env::temp_dir().join("coven-mismatched-s3-locator");
2019 let file_error = ExactSlotStorage::read_at_to_file(&home, &slot, &destination)
2020 .await
2021 .expect_err("opaque S3 file read must fail");
2022 assert!(file_error.to_string().contains("must use its logical key"));
2023 let delete_error = ExactSlotStorage::delete_at(&home, &slot)
2024 .await
2025 .expect_err("opaque S3 delete must fail");
2026 assert!(delete_error
2027 .to_string()
2028 .contains("must use its logical key"));
2029 }
2030
2031 #[tokio::test]
2032 async fn provider_binding_canonicalizes_the_custom_origin_and_hashes_the_access_key_id() {
2033 use coven_core::sync::storage::{
2034 ProviderPrincipalId, S3EndpointBinding, StoreProviderBinding,
2035 };
2036
2037 let home = S3CloudHome::new(
2038 "bucket-a".to_string(),
2039 "us-east-1".to_string(),
2040 Some("https://objects.example:443".to_string()),
2041 "access-key".to_string(),
2042 "secret-key".to_string(),
2043 Some("stores/a/".to_string()),
2044 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
2045 )
2046 .await
2047 .expect("construct custom S3 home");
2048
2049 let binding = ExactSlotStorage::provider_binding(&home)
2050 .await
2051 .expect("resolve S3 provider binding");
2052
2053 assert_eq!(
2054 binding.store,
2055 StoreProviderBinding::S3 {
2056 endpoint: S3EndpointBinding::Custom {
2057 origin: "https://objects.example".to_string(),
2058 },
2059 region: "us-east-1".to_string(),
2060 bucket: "bucket-a".to_string(),
2061 key_prefix: Some("stores/a".to_string()),
2062 }
2063 );
2064 assert_eq!(
2065 binding.device.principal,
2066 ProviderPrincipalId::CustomS3Credential {
2067 access_key_id_hash: s3_access_key_id_hash("access-key"),
2068 }
2069 );
2070 }
2071
2072 #[tokio::test]
2073 async fn provider_binding_rejects_a_custom_endpoint_with_a_base_path() {
2074 let home = S3CloudHome::new(
2075 "bucket-a".to_string(),
2076 "us-east-1".to_string(),
2077 Some("https://objects.example/s3".to_string()),
2078 "access-key".to_string(),
2079 "secret-key".to_string(),
2080 None,
2081 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
2082 )
2083 .await
2084 .expect("construct custom S3 home");
2085
2086 let error = ExactSlotStorage::provider_binding(&home)
2087 .await
2088 .expect_err("a non-origin custom endpoint cannot be signed as an origin");
2089
2090 assert!(error.to_string().contains("origin"), "{error}");
2091 }
2092
2093 #[test]
2094 fn sts_transport_failure_remains_retryable_transport() {
2095 let error = sts_request_error("offline");
2096 assert!(matches!(error, CloudHomeError::Transport(_)));
2097 assert!(error.is_retryable());
2098 }
2099
2100 #[test]
2101 fn sts_identity_accepts_stable_aws_principals_and_rejects_federated_users() {
2102 use coven_core::sync::storage::AwsPrincipal;
2103
2104 assert_eq!(
2105 aws_caller_identity(
2106 "123456789012",
2107 "arn:aws:iam::123456789012:user/path/alice",
2108 "AIDAEXAMPLE"
2109 )
2110 .unwrap(),
2111 (
2112 "aws".to_string(),
2113 AwsPrincipal::User {
2114 arn: "arn:aws:iam::123456789012:user/path/alice".to_string(),
2115 user_id: "AIDAEXAMPLE".to_string(),
2116 }
2117 )
2118 );
2119 assert_eq!(
2120 aws_caller_identity(
2121 "123456789012",
2122 "arn:aws:sts::123456789012:assumed-role/path/role/session",
2123 "AROAEXAMPLE:session"
2124 )
2125 .unwrap()
2126 .1,
2127 AwsPrincipal::Role {
2128 role_id: "AROAEXAMPLE".to_string()
2129 }
2130 );
2131 assert!(aws_caller_identity(
2132 "123456789012",
2133 "arn:aws:sts::123456789012:federated-user/alice",
2134 "123456789012:alice"
2135 )
2136 .is_err());
2137 }
2138
2139 #[test]
2140 fn cancellation_abort_failure_does_not_terminate_the_process() {
2141 const CHILD: &str = "COVEN_S3_CANCEL_ABORT_CHILD";
2142 if std::env::var_os(CHILD).is_some() {
2143 let runtime = tokio::runtime::Runtime::new().unwrap();
2144 runtime.block_on(async {
2145 let bucket = "immutable-cancel-failure-test".to_string();
2146 let abort_failures = Arc::new(AtomicUsize::new(1));
2147 let (endpoint, shutdown) = spawn_fake_s3(
2148 Router::new()
2149 .fallback(fake_s3_body_failure_endpoint)
2150 .with_state((bucket.clone(), abort_failures.clone())),
2151 )
2152 .await;
2153 let home = S3CloudHome::new(
2154 bucket,
2155 "us-east-1".to_string(),
2156 Some(endpoint),
2157 "access-key".to_string(),
2158 "secret-key".to_string(),
2159 None,
2160 Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
2161 )
2162 .await
2163 .unwrap();
2164 let sink = home
2165 .open_multipart_sink("immutable/cancelled", MultipartCompletion::CreateOnly)
2166 .await
2167 .unwrap();
2168 drop(sink);
2169 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2170 while abort_failures.load(Ordering::SeqCst) != 0 {
2171 tokio::task::yield_now().await;
2172 }
2173 })
2174 .await
2175 .expect("multipart owner must finish the abort request");
2176 shutdown.send(()).expect("shut down fake S3");
2177 });
2178 std::process::exit(0);
2179 }
2180
2181 let status = std::process::Command::new(std::env::current_exe().unwrap())
2182 .arg("cancellation_abort_failure_does_not_terminate_the_process")
2183 .arg("--nocapture")
2184 .env(CHILD, "1")
2185 .status()
2186 .expect("run S3 cancellation sabotage subprocess");
2187 assert!(
2188 status.success(),
2189 "multipart abort failure terminated the subprocess: {status}"
2190 );
2191 }
2192
2193 #[tokio::test]
2194 async fn read_range_accepts_s3_compatible_full_object_checksum_header() {
2195 let range_body = b"abcdefghijklmnopqrstuvwx".to_vec();
2196 let key = "storage/audio-object".to_string();
2197 let bucket = "coven-s3-compatible-test".to_string();
2198 let (endpoint, shutdown) = spawn_fake_s3_endpoint(FakeRangeObject {
2199 bucket: bucket.clone(),
2200 key: key.clone(),
2201 range_body: range_body.clone(),
2202 object_len: 96,
2203 whole_object_crc32c: "sNqCyA==",
2204 })
2205 .await;
2206
2207 let home = S3CloudHome::new(
2208 bucket,
2209 "us-central1".to_string(),
2210 Some(endpoint),
2211 "access-key".to_string(),
2212 "secret-key".to_string(),
2213 None,
2214 None,
2215 )
2216 .await
2217 .expect("construct S3CloudHome");
2218
2219 let bytes = home
2220 .read_range(&key, 0, range_body.len() as u64)
2221 .await
2222 .expect("read range");
2223
2224 assert_eq!(bytes, range_body);
2225 shutdown.send(()).expect("shut down fake S3");
2226 }
2227
2228 #[tokio::test]
2233 async fn read_range_rejects_full_object_200_response() {
2234 let full_body = b"0123456789abcdefghijklmnopqrstuvwxyz".to_vec();
2235 let key = "storage/audio-object".to_string();
2236 let bucket = "coven-s3-ignores-range".to_string();
2237 let (endpoint, shutdown) = spawn_fake_s3_full_body_endpoint(FakeFullBodyObject {
2238 bucket: bucket.clone(),
2239 key: key.clone(),
2240 full_body,
2241 })
2242 .await;
2243
2244 let home = S3CloudHome::new(
2245 bucket,
2246 "us-east-1".to_string(),
2247 Some(endpoint),
2248 "access-key".to_string(),
2249 "secret-key".to_string(),
2250 None,
2251 None,
2252 )
2253 .await
2254 .expect("construct S3CloudHome");
2255
2256 let err = home
2257 .read_range(&key, 8, 16)
2258 .await
2259 .expect_err("a 200 full-object response to a range request must error");
2260 assert!(matches!(err, CloudHomeError::Transport(_)), "got {err:?}");
2261 shutdown.send(()).expect("shut down fake S3");
2262 }
2263
2264 #[tokio::test]
2265 async fn exact_read_streams_object_to_file() {
2266 let full_body = b"0123456789abcdefghijklmnopqrstuvwxyz".to_vec();
2267 let key = "storage/audio-object".to_string();
2268 let bucket = "coven-s3-appended-read".to_string();
2269 let (endpoint, shutdown) = spawn_fake_s3_full_body_endpoint(FakeFullBodyObject {
2270 bucket: bucket.clone(),
2271 key: key.clone(),
2272 full_body: full_body.clone(),
2273 })
2274 .await;
2275
2276 let home = S3CloudHome::new(
2277 bucket,
2278 "us-east-1".to_string(),
2279 Some(endpoint),
2280 "access-key".to_string(),
2281 "secret-key".to_string(),
2282 None,
2283 None,
2284 )
2285 .await
2286 .expect("construct S3CloudHome");
2287 let tmp = tempfile::tempdir().expect("temp dir");
2288 let destination = tmp.path().join("object.bin");
2289 let slot = ObjectSlot::logical(key).unwrap();
2290
2291 ExactSlotStorage::read_at_to_file(&home, &slot, &destination)
2292 .await
2293 .expect("stream exact object");
2294
2295 assert_eq!(
2296 tokio::fs::read(&destination)
2297 .await
2298 .expect("read destination"),
2299 full_body
2300 );
2301 shutdown.send(()).expect("shut down fake S3");
2302 }
2303
2304 #[tokio::test]
2305 async fn canceling_exact_read_cannot_rename_over_destination_later() {
2306 let key = "storage/cancel-object".to_string();
2307 let bucket = "coven-s3-cancel-read".to_string();
2308 let first = b"partial".to_vec();
2309 let first_sent = Arc::new(tokio::sync::Notify::new());
2310 let release_second = Arc::new(tokio::sync::Notify::new());
2311 let (endpoint, shutdown) = spawn_fake_s3_paused_body_endpoint(FakePausedBodyObject {
2312 bucket: bucket.clone(),
2313 key: key.clone(),
2314 first: first.clone(),
2315 second: b" remainder".to_vec(),
2316 first_sent: first_sent.clone(),
2317 release_second: release_second.clone(),
2318 })
2319 .await;
2320
2321 let home = S3CloudHome::new(
2322 bucket,
2323 "us-east-1".to_string(),
2324 Some(endpoint),
2325 "access-key".to_string(),
2326 "secret-key".to_string(),
2327 None,
2328 None,
2329 )
2330 .await
2331 .expect("construct S3CloudHome");
2332 let tmp = tempfile::tempdir().expect("temp dir");
2333 let destination = tmp.path().join("object.bin");
2334 tokio::fs::write(&destination, b"committed")
2335 .await
2336 .expect("seed destination");
2337 let slot = ObjectSlot::logical(key).unwrap();
2338 let read_destination = destination.clone();
2339 let read = tokio::spawn(async move {
2340 ExactSlotStorage::read_at_to_file(&home, &slot, &read_destination).await
2341 });
2342 first_sent.notified().await;
2343 tokio::time::timeout(std::time::Duration::from_secs(1), async {
2344 loop {
2345 if atomic_temp_paths(tmp.path()).await.iter().any(|path| {
2346 std::fs::metadata(path)
2347 .is_ok_and(|metadata| metadata.len() == first.len() as u64)
2348 }) {
2349 break;
2350 }
2351 tokio::task::yield_now().await;
2352 }
2353 })
2354 .await
2355 .expect("first response chunk was written to the temp file");
2356
2357 read.abort();
2358 assert!(read.await.expect_err("read task canceled").is_cancelled());
2359 release_second.notify_waiters();
2360 tokio::time::timeout(std::time::Duration::from_secs(1), async {
2361 loop {
2362 if atomic_temp_paths(tmp.path()).await.is_empty() {
2363 break;
2364 }
2365 tokio::task::yield_now().await;
2366 }
2367 })
2368 .await
2369 .expect("canceled S3 task removed its temp file");
2370
2371 assert_eq!(
2372 tokio::fs::read(&destination)
2373 .await
2374 .expect("read destination"),
2375 b"committed"
2376 );
2377 shutdown.send(()).expect("shut down fake S3");
2378 }
2379
2380 #[tokio::test]
2381 async fn list_errors_when_truncated_response_has_no_continuation_token() {
2382 let bucket = "coven-s3-list-test".to_string();
2383 let (endpoint, shutdown, request_count) =
2384 spawn_fake_s3_truncated_list_endpoint(bucket.clone()).await;
2385
2386 let home = S3CloudHome::new(
2387 bucket,
2388 "us-central1".to_string(),
2389 Some(endpoint),
2390 "access-key".to_string(),
2391 "secret-key".to_string(),
2392 None,
2393 None,
2394 )
2395 .await
2396 .expect("construct S3CloudHome");
2397
2398 let result = tokio::time::timeout(std::time::Duration::from_secs(1), home.list("objects/"))
2399 .await
2400 .expect("list should return instead of refetching the first page");
2401 let err = result.expect_err("truncated response without token must fail");
2402 let msg = err.to_string();
2403
2404 assert!(
2405 msg.contains("truncated") && msg.contains("continuation token"),
2406 "unexpected error: {msg}"
2407 );
2408 assert_eq!(
2409 request_count.load(Ordering::SeqCst),
2410 1,
2411 "malformed page must not be refetched"
2412 );
2413 let _ = shutdown.send(());
2414 }
2415
2416 #[tokio::test]
2417 async fn s3_revoke_access_reports_unsupported() {
2418 let home = S3CloudHome::new(
2419 "bucket".to_string(),
2420 "us-east-1".to_string(),
2421 Some("http://127.0.0.1:9".to_string()),
2422 "access-key".to_string(),
2423 "secret-key".to_string(),
2424 None,
2425 None,
2426 )
2427 .await
2428 .expect("construct S3CloudHome");
2429
2430 let outcome = home
2431 .set_access(CloudAccessState::Absent {
2432 member_pubkey: "member-pubkey".to_string(),
2433 provider_account_email: None,
2434 })
2435 .await
2436 .expect("S3 revoke_access must not error so member removal completes");
2437
2438 assert_eq!(
2439 outcome,
2440 CloudAccessOutcome::Absent(RevokeOutcome::Unsupported),
2441 "S3 hands out one static bucket credential that cannot be withdrawn per member, so it reports Unsupported rather than claiming a revocation it did not perform",
2442 );
2443 }
2444
2445 fn test_env(name: &str, default: &str) -> String {
2457 match std::env::var(name) {
2458 Ok(s) => s,
2459 Err(std::env::VarError::NotPresent) => default.to_string(),
2460 Err(std::env::VarError::NotUnicode(raw)) => {
2461 panic!("test env var {name} is non-utf8: {raw:?}");
2462 }
2463 }
2464 }
2465
2466 struct TestCreds {
2467 endpoint: String,
2468 access_key: String,
2469 secret_key: String,
2470 }
2471
2472 fn test_creds() -> TestCreds {
2473 TestCreds {
2474 endpoint: test_env("COVEN_TEST_S3_URL", "http://localhost:19000"),
2475 access_key: test_env("COVEN_TEST_S3_KEY", "coventest"),
2476 secret_key: test_env("COVEN_TEST_S3_SECRET", "coventestpass"),
2477 }
2478 }
2479
2480 fn required_test_env(name: &str) -> String {
2481 match std::env::var(name) {
2482 Ok(s) => s,
2483 Err(std::env::VarError::NotPresent) => {
2484 panic!("test env var {name} must be set for this test");
2485 }
2486 Err(std::env::VarError::NotUnicode(raw)) => {
2487 panic!("test env var {name} is non-utf8: {raw:?}");
2488 }
2489 }
2490 }
2491
2492 fn optional_test_env(name: &str) -> Option<String> {
2493 match std::env::var(name) {
2494 Ok(s) => Some(s),
2495 Err(std::env::VarError::NotPresent) => None,
2496 Err(std::env::VarError::NotUnicode(raw)) => {
2497 panic!("test env var {name} is non-utf8: {raw:?}");
2498 }
2499 }
2500 }
2501
2502 struct ExistingS3ObjectEnv {
2503 bucket: String,
2504 region: String,
2505 endpoint: String,
2506 key: String,
2507 access_key: String,
2508 secret_key: String,
2509 }
2510
2511 fn existing_s3_object_env() -> Option<ExistingS3ObjectEnv> {
2512 let names = [
2513 "COVEN_TEST_S3_BUCKET",
2514 "COVEN_TEST_S3_REGION",
2515 "COVEN_TEST_S3_URL",
2516 "COVEN_TEST_S3_EXISTING_KEY",
2517 "COVEN_TEST_S3_KEY",
2518 "COVEN_TEST_S3_SECRET",
2519 ];
2520 let mut values = Vec::with_capacity(names.len());
2521 let mut missing = Vec::new();
2522 for name in names {
2523 match optional_test_env(name) {
2524 Some(value) => values.push(value),
2525 None => missing.push(name),
2526 }
2527 }
2528 if !missing.is_empty() {
2529 eprintln!(
2530 "skipping live S3 object test; unset env vars: {}",
2531 missing.join(", ")
2532 );
2533 return None;
2534 }
2535 let [bucket, region, endpoint, key, access_key, secret_key]: [String; 6] =
2536 values.try_into().expect("collected every live S3 env var");
2537 Some(ExistingS3ObjectEnv {
2538 bucket,
2539 region,
2540 endpoint,
2541 key,
2542 access_key,
2543 secret_key,
2544 })
2545 }
2546
2547 #[tokio::test]
2548 #[ignore]
2549 async fn read_range_succeeds_against_existing_s3_object() {
2550 let creds = test_creds();
2551 let bucket = required_test_env("COVEN_TEST_S3_BUCKET");
2552 let region = test_env("COVEN_TEST_S3_REGION", "us-east-1");
2553 let key = required_test_env("COVEN_TEST_S3_EXISTING_KEY");
2554 let start: u64 = test_env("COVEN_TEST_S3_RANGE_START", "0")
2555 .parse()
2556 .expect("COVEN_TEST_S3_RANGE_START must be a u64");
2557 let end: u64 = test_env("COVEN_TEST_S3_RANGE_END", "24")
2558 .parse()
2559 .expect("COVEN_TEST_S3_RANGE_END must be a u64");
2560
2561 let home = S3CloudHome::new(
2562 bucket,
2563 region,
2564 Some(creds.endpoint),
2565 creds.access_key,
2566 creds.secret_key,
2567 None,
2568 None,
2569 )
2570 .await
2571 .expect("construct S3CloudHome");
2572
2573 eprintln!("reading {key} range {start}..{end}");
2574 let bytes = home
2575 .read_range(&key, start, end)
2576 .await
2577 .unwrap_or_else(|e| panic!("read_range failed: {e:?}"));
2578
2579 assert_eq!(bytes.len() as u64, end - start);
2580 }
2581
2582 #[tokio::test]
2593 #[ignore]
2594 async fn s3_big_stack_reads_real_bytes_from_existing_object() {
2595 let Some(env) = existing_s3_object_env() else {
2596 return;
2597 };
2598
2599 let home = S3CloudHome::new(
2600 env.bucket,
2601 env.region,
2602 Some(env.endpoint),
2603 env.access_key,
2604 env.secret_key,
2605 None,
2606 None,
2607 )
2608 .await
2609 .expect("construct S3CloudHome");
2610
2611 let whole = home
2612 .read(&env.key)
2613 .await
2614 .unwrap_or_else(|e| panic!("read({}) failed: {e:?}", env.key));
2615 assert!(
2616 !whole.is_empty(),
2617 "expected non-empty object at {}",
2618 env.key
2619 );
2620 eprintln!("read {} bytes from {}", whole.len(), env.key);
2621
2622 let n = whole.len().min(16) as u64;
2623 let head = home
2624 .read_range(&env.key, 0, n)
2625 .await
2626 .unwrap_or_else(|e| panic!("read_range({}, 0..{n}) failed: {e:?}", env.key));
2627 assert_eq!(
2628 head.as_slice(),
2629 &whole[..n as usize],
2630 "range bytes must match the object's prefix"
2631 );
2632 eprintln!("read_range first {n} bytes match the full read");
2633 }
2634
2635 async fn provision_test_bucket(home: &S3CloudHome) {
2637 home.client
2638 .create_bucket()
2639 .bucket(&home.bucket)
2640 .send()
2641 .await
2642 .expect("create test bucket");
2643 }
2644
2645 #[tokio::test]
2646 #[ignore]
2647 async fn probe_succeeds_against_existing_bucket() {
2648 let creds = test_creds();
2649 let bucket = format!("coven-probe-ok-{}", uuid::Uuid::new_v4());
2650 let home = S3CloudHome::new(
2651 bucket,
2652 "us-east-1".to_string(),
2653 Some(creds.endpoint),
2654 creds.access_key,
2655 creds.secret_key,
2656 None,
2657 None,
2658 )
2659 .await
2660 .expect("construct S3CloudHome");
2661 provision_test_bucket(&home).await;
2662 home.probe().await.expect("probe should succeed");
2663 }
2664
2665 #[tokio::test]
2666 #[ignore]
2667 async fn probe_fails_for_missing_bucket() {
2668 let creds = test_creds();
2669 let bucket = format!("coven-probe-missing-{}", uuid::Uuid::new_v4());
2670 let home = S3CloudHome::new(
2671 bucket.clone(),
2672 "us-east-1".to_string(),
2673 Some(creds.endpoint),
2674 creds.access_key,
2675 creds.secret_key,
2676 None,
2677 None,
2678 )
2679 .await
2680 .expect("construct S3CloudHome");
2681 let err = home
2683 .probe()
2684 .await
2685 .expect_err("probe should fail for a missing bucket");
2686 let msg = format!("{err}");
2687 assert!(
2688 msg.contains("does not exist") || msg.contains("NoSuchBucket") || msg.contains("404"),
2689 "expected missing-bucket error, got: {msg}",
2690 );
2691 }
2692
2693 #[tokio::test]
2694 #[ignore]
2695 async fn probe_fails_for_bad_secret_key() {
2696 let creds = test_creds();
2697 let bucket = format!("coven-probe-badkey-{}", uuid::Uuid::new_v4());
2698 let good = S3CloudHome::new(
2700 bucket.clone(),
2701 "us-east-1".to_string(),
2702 Some(creds.endpoint.clone()),
2703 creds.access_key.clone(),
2704 creds.secret_key,
2705 None,
2706 None,
2707 )
2708 .await
2709 .expect("construct good S3CloudHome");
2710 provision_test_bucket(&good).await;
2711
2712 let bad = S3CloudHome::new(
2713 bucket,
2714 "us-east-1".to_string(),
2715 Some(creds.endpoint),
2716 creds.access_key,
2717 "wrong-secret".to_string(),
2718 None,
2719 None,
2720 )
2721 .await
2722 .expect("construct bad S3CloudHome");
2723 let err = bad
2724 .probe()
2725 .await
2726 .expect_err("probe should fail for bad credentials");
2727 let msg = format!("{err}");
2728 assert!(
2729 msg.contains("rejected")
2730 || msg.contains("403")
2731 || msg.contains("SignatureDoesNotMatch"),
2732 "expected credentials error, got: {msg}",
2733 );
2734 }
2735}