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