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