Skip to main content

coven/storage/cloud/
s3.rs

1//! S3-backed `CloudHome` implementation.
2//!
3//! Wraps `aws-sdk-s3` to provide raw storage operations against any
4//! S3-compatible endpoint.
5
6use 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
23/// A coven-owned tokio runtime whose worker threads have a large stack, used to
24/// run every aws-sdk call.
25///
26/// The reason it exists: aws-sdk-s3's endpoint resolver
27/// (`DefaultResolver::resolve_endpoint`, a giant generated function) descends
28/// deep *synchronously in one poll*. When a `CloudHome` S3 call is awaited on a
29/// foreign UI executor thread (Swift's cooperative pool / Kotlin's dispatcher,
30/// ~0.5 MiB stack) the descent overflows that stack → SIGBUS. Spawning every aws
31/// interaction onto a worker of this runtime guarantees the descent always runs
32/// on a big stack, no matter who awaits the `CloudHome` method — so the host no
33/// longer has to know which calls are "deep" and hand-wrap them.
34fn 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) // aws endpoint resolver needs >> a UI thread's ~0.5 MiB
40            .thread_name("coven-s3")
41            .enable_all() // io + time: the aws connector/sleep need both
42            .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
77/// Run an S3 interaction on the big-stack runtime, flattening the join-handle
78/// result into the future's own `Result<T, CloudHomeError>`.
79/// The future must be `Send + 'static` (owned args, a cloned `Client`).
80async 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/// S3-backed cloud home.
90#[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        // aws-config has default-features disabled, so the SDK won't auto-bundle
130        // an HTTP client. Plug in the rustls-ring smithy client explicitly.
131        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            // The SDK default stalled-stream protection aborts any body
142            // transfer that stays under 1 B/s for 5 seconds — on slow or
143            // briefly-stalling links that kills large legitimate downloads
144            // (a pinned release's full-object GETs) with "minimum throughput
145            // was specified at 1 B/s, but throughput of 0 B/s was observed".
146            // Keep the protection (a truly dead stream should still error;
147            // uploads retry from the durable outbox) but give real-world
148            // stalls a 60-second grace window.
149            .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            // Coven's S3 backend is intentionally S3-compatible, not AWS-only.
163            //
164            // The AWS SDK default is ResponseChecksumValidation::WhenSupported.
165            // For GetObject that default mutates the request to checksum-mode=ENABLED,
166            // then validates any returned x-amz-checksum-* header against the response
167            // body. That is correct for AWS S3's modeled checksum behavior, but it is
168            // not a portable integrity layer for S3-compatible providers.
169            //
170            // Google Cloud Storage's S3-compatible API returns
171            // x-amz-checksum-crc32c on ranged GetObject responses with the checksum of
172            // the whole object. A Range: bytes=0-23 response legitimately contains only
173            // those 24 bytes, so validating that partial body against the full-object
174            // checksum fails with a checksum mismatch before playback can read the
175            // encrypted nonce header.
176            //
177            // Do not use provider checksum headers as coven's generic byte-integrity
178            // contract. Managed encrypted blobs are authenticated by their AEAD tags
179            // during decrypt; plaintext cloud integrity needs coven-owned metadata or
180            // chunk hashes, not provider-specific response-header semantics.
181            .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            // Normalize once here (trim trailing slash, drop empty), so neither
197            // full_key nor list re-trims it.
198            key_prefix: normalize_prefix(key_prefix),
199            exact_slots,
200        })
201    }
202
203    /// Prepend the key prefix (if configured) to produce the full S3 object key.
204    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/// A [`PartSink`] over an open S3 multipart upload: each `send_part` is one
331/// `upload_part` whose ETag is kept, and `finish` is `complete_multipart_upload`.
332/// The owner task holds the multipart state and waits for every S3 request. On
333/// normal completion or failure the caller joins it; cancellation closes the
334/// command channel and the owner waits for abort without blocking `Drop`.
335#[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    /// The prefixed object key (also used in error messages).
359    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
634/// Files at or below this size go up as a single PUT; larger files use a
635/// multipart upload so progress advances per part. The threshold equals the
636/// part size, so the smallest multipart upload is two parts.
637const MULTIPART_THRESHOLD: usize = 8 * 1024 * 1024;
638
639/// Multipart part size. S3 requires every part except the last to be at least
640/// 5 MiB; 8 MiB keeps the part count (and request count) reasonable for large
641/// audio files while still giving several progress ticks.
642const 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
657/// Map a GetObject failure to a `CloudHomeError`, surfacing the S3 error code and
658/// message (e.g. `AccessDenied`, `PermanentRedirect`, `SignatureDoesNotMatch`)
659/// rather than the opaque "service error". `NoSuchKey` becomes `NotFound`;
660/// non-service failures (timeouts, connection errors) fall back to their own
661/// description. Generic over the response type so it serves both `read` and
662/// `read_range` without naming the smithy HTTP type.
663fn 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        // Not a service error (timeout / connection / dispatch) — its own
675        // Display carries the detail.
676        None => CloudHomeError::Transport(format!("get {key}: {err}")),
677    }
678}
679
680/// Map a PutObject failure to a `CloudHomeError`. The common failure modes
681/// each name the cause and the recovery the user can take:
682///
683/// - `AccessDenied` — bucket policy or IAM rejects writes. User fixes via
684///   sync settings.
685/// - `NoSuchBucket` — bucket was renamed/deleted out from under us.
686/// - `OverQuota` / `QuotaExceeded` — non-AWS S3 providers (Backblaze, MinIO)
687///   signal quota exhaustion through these codes.
688///
689/// Other service errors keep the raw code + message so they're debuggable;
690/// transport failures surface their own Display.
691fn 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    /// HeadBucket — cheap auth + existence check, no listing cost.
717    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                    // The shared 404→missing / 403→creds-rejected classification both
729                    // S3 backends use.
730                    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        // The body `collect()` runs inside the spawn too: streaming the response
788        // drives the same aws connector that needs the big stack.
789        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            // A ranged GET is honored only with 206 Partial Content; a 200 means
876            // the provider ignored `Range` and returned the whole object from
877            // byte 0. The aws-sdk `GetObjectOutput` doesn't surface the raw HTTP
878            // status, so verify the equivalent invariant the reqwest transports
879            // check by status: the body must be exactly the requested byte count
880            // (the `CloudHome` contract never reads past the object's end).
881            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        // The whole continuation loop is one spawned task: every page's `send`
902        // runs on the big-stack runtime.
903        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                // Delete is idempotent: AWS S3 returns 204 for an already-absent key,
972                // but GCS's S3 XML API returns 404 `NoSuchKey`. A missing object is not
973                // a failure. Exact cleanup operations are retried after uncertain
974                // outcomes, so deleting an already-absent object must succeed. Swallow
975                // not-found and surface only real errors.
976                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                // Apply the shared not-found rule (NoSuchKey/NotFound, or a raw 404)
995                // off the modeled error code and status, not a Display-string match.
996                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    /// Bind, serve, and wire graceful shutdown for a fake S3 server — the
1284    /// scaffolding every fake endpoint shares; each test supplies its Router.
1285    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    /// A fake S3 that ignores `Range` and answers every GET with 200 and the
1324    /// whole object — the provider-ignores-range failure the range verification
1325    /// must catch.
1326    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    /// A provider that ignores `Range` and answers a ranged GET with 200 and the
2229    /// whole object returns offset-0 bytes where a mid-file range was asked for —
2230    /// silent corruption on a plaintext home. The range read must reject the
2231    /// mismatched body, not return the wrong bytes.
2232    #[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    // ── probe() against a real S3 endpoint ──────────────────────────────
2446    //
2447    // These tests require a minio (or any S3-compatible server) reachable at
2448    // `COVEN_TEST_S3_URL` (default http://localhost:19000) with credentials
2449    // `COVEN_TEST_S3_KEY` / `COVEN_TEST_S3_SECRET` (default minioadmin / minioadmin).
2450    // Marked `#[ignore]` so `cargo test` skips them; run with
2451    // `cargo test -- --ignored` when an endpoint is available.
2452
2453    /// Read a test env var with a default fallback. `NotPresent` silently uses
2454    /// the default (the intended path); `NotUnicode` panics so a misconfigured
2455    /// env var fails loudly instead of silently substituting bytes-as-default.
2456    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    /// Proves an `S3CloudHome`'s aws calls run end to end on `s3_runtime` — a
2583    /// different runtime than the `Client` was built on — against a real bucket:
2584    /// connection establishment, TLS, and body streaming, not just "it compiles".
2585    /// If the aws connector bound to the build-time runtime's reactor this would
2586    /// panic with "no reactor running" or hang; a real byte vec back proves the
2587    /// spawn-on-other-runtime path works.
2588    ///
2589    /// Reads an existing object from a real S3-compatible bucket. Coordinates
2590    /// come from `COVEN_TEST_S3_BUCKET`, `COVEN_TEST_S3_REGION`,
2591    /// `COVEN_TEST_S3_URL`, and `COVEN_TEST_S3_EXISTING_KEY`.
2592    #[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    /// Provision the bucket configured on `home`.
2636    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        // Deliberately do NOT create the bucket.
2682        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        // Provision the bucket with the good creds so the only difference is the bad secret.
2699        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}