Skip to main content

fakecloud_s3/service/
mod.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bytes::Bytes;
5use chrono::{DateTime, Timelike, Utc};
6use http::{HeaderMap, Method, StatusCode};
7use md5::{Digest, Md5};
8
9use fakecloud_core::delivery::DeliveryBus;
10use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
11use fakecloud_kms::state::SharedKmsState;
12use fakecloud_persistence::{MemoryS3Store, S3Store, StoreError};
13
14use base64::engine::general_purpose::STANDARD as BASE64;
15use base64::Engine as _;
16
17use crate::logging;
18use crate::state::{AclGrant, S3Bucket, S3Object, SharedS3State};
19
20mod acl;
21mod buckets;
22mod config;
23mod lock;
24mod multipart;
25mod notifications;
26mod objects;
27mod tags;
28
29// Re-export notification helpers for use in sub-modules
30#[cfg(test)]
31use notifications::replicate_object;
32pub(super) use notifications::{
33    deliver_notifications, normalize_notification_ids, normalize_replication_xml,
34    replicate_through_store,
35};
36
37// Used only within this file (parse_cors_config)
38use notifications::extract_all_xml_values;
39
40// Re-exports used only in tests
41#[cfg(test)]
42use notifications::{
43    event_matches, key_matches_filters, parse_notification_config, parse_replication_rules,
44    NotificationTargetType,
45};
46
47pub struct S3Service {
48    state: SharedS3State,
49    delivery: Arc<DeliveryBus>,
50    kms_state: Option<SharedKmsState>,
51    pub(crate) kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
52    #[allow(dead_code)]
53    store: Arc<dyn S3Store>,
54}
55
56/// Map a [`StoreError`] from the persistence layer to a 500 InternalError
57/// response. Invoked at every mutation site when the write-through persistence
58/// call fails: the in-memory mutation has already happened, but we surface the
59/// failure to the client so they know to retry (and so logs/metrics flag it).
60pub(crate) fn persistence_error(err: StoreError) -> AwsServiceError {
61    AwsServiceError::aws_error(
62        StatusCode::INTERNAL_SERVER_ERROR,
63        "InternalError",
64        format!("persistence store error: {err}"),
65    )
66}
67
68/// Convert a filesystem IO error from a disk-backed body read into an
69/// InternalError response.
70pub(crate) fn io_to_aws(err: std::io::Error) -> AwsServiceError {
71    AwsServiceError::aws_error(
72        StatusCode::INTERNAL_SERVER_ERROR,
73        "InternalError",
74        format!("failed to read object body from disk: {err}"),
75    )
76}
77
78impl S3Service {
79    pub fn new(state: SharedS3State, delivery: Arc<DeliveryBus>) -> Self {
80        Self::with_store(state, delivery, Arc::new(MemoryS3Store::new()))
81    }
82
83    pub fn with_store(
84        state: SharedS3State,
85        delivery: Arc<DeliveryBus>,
86        store: Arc<dyn S3Store>,
87    ) -> Self {
88        Self {
89            state,
90            delivery,
91            kms_state: None,
92            kms_hook: None,
93            store,
94        }
95    }
96
97    pub fn with_kms(mut self, kms_state: SharedKmsState) -> Self {
98        self.kms_state = Some(kms_state);
99        self
100    }
101
102    pub fn with_kms_hook(mut self, hook: Arc<dyn fakecloud_core::delivery::KmsHook>) -> Self {
103        self.kms_hook = Some(hook);
104        self
105    }
106
107    /// Encrypt object body bytes for SSE-KMS storage. Returns ciphertext as
108    /// raw bytes (a UTF-8 fakecloud-kms envelope) on success.
109    ///
110    /// Fail-closed: if the KMS hook reports an error (key denied, key not
111    /// found, etc.), this returns `Err` so PutObject aborts with a 500
112    /// rather than silently storing plaintext. AWS S3 has the same
113    /// behavior — `KMS.NotFoundException` and friends surface as
114    /// `AccessDenied` / `KMS.*` errors back to the caller. When no hook is
115    /// wired (legacy / in-process tests with no KMS dependency), the
116    /// plaintext is returned unchanged so existing tests keep working.
117    pub(crate) fn encrypt_object_body(
118        &self,
119        account_id: &str,
120        region: &str,
121        bucket: &str,
122        plaintext: &[u8],
123        kms_key_id: Option<&str>,
124    ) -> Result<bytes::Bytes, AwsServiceError> {
125        let Some(hook) = &self.kms_hook else {
126            return Ok(bytes::Bytes::copy_from_slice(plaintext));
127        };
128        let key = kms_key_id.filter(|k| !k.is_empty()).unwrap_or("aws/s3");
129        let bucket_arn = format!("arn:aws:s3:::{bucket}");
130        let mut ctx = std::collections::HashMap::new();
131        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
132        match hook.encrypt(account_id, region, key, plaintext, "s3.amazonaws.com", ctx) {
133            Ok(envelope) => Ok(bytes::Bytes::from(envelope.into_bytes())),
134            Err(err) => {
135                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS encrypt failed");
136                Err(AwsServiceError::aws_error(
137                    StatusCode::INTERNAL_SERVER_ERROR,
138                    "KMS.InternalFailureException",
139                    format!("Failed to encrypt object via KMS: {err}"),
140                ))
141            }
142        }
143    }
144
145    /// Decrypt object body bytes that were stored as a fakecloud-kms
146    /// envelope. Caller is expected to gate this on
147    /// `obj.sse_algorithm == Some("aws:kms")`.
148    ///
149    /// Fail-closed: when a hook is wired and the bytes look like an
150    /// envelope but don't decrypt (key revoked, malformed ciphertext),
151    /// this returns `Err` so GetObject surfaces a 500. When no hook is
152    /// wired, or the bytes aren't UTF-8 (legacy snapshots from before
153    /// the hook landed), the bytes are returned unchanged.
154    pub(crate) fn decrypt_object_body(
155        &self,
156        account_id: &str,
157        bucket: &str,
158        ciphertext: &[u8],
159    ) -> Result<bytes::Bytes, AwsServiceError> {
160        let Some(hook) = &self.kms_hook else {
161            return Ok(bytes::Bytes::copy_from_slice(ciphertext));
162        };
163        // Stored envelope is base64 ASCII; non-UTF-8 bytes are pre-hook
164        // legacy snapshots, return as-is.
165        let envelope = match std::str::from_utf8(ciphertext) {
166            Ok(s) => s,
167            Err(_) => return Ok(bytes::Bytes::copy_from_slice(ciphertext)),
168        };
169        let bucket_arn = format!("arn:aws:s3:::{bucket}");
170        let mut ctx = std::collections::HashMap::new();
171        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
172        match hook.decrypt(account_id, envelope, "s3.amazonaws.com", ctx) {
173            Ok(bytes) => Ok(bytes::Bytes::from(bytes)),
174            Err(err) => {
175                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS decrypt failed");
176                Err(AwsServiceError::aws_error(
177                    StatusCode::INTERNAL_SERVER_ERROR,
178                    "KMS.InternalFailureException",
179                    format!("Failed to decrypt object via KMS: {err}"),
180                ))
181            }
182        }
183    }
184}
185
186#[async_trait]
187impl AwsService for S3Service {
188    fn service_name(&self) -> &str {
189        "s3"
190    }
191
192    async fn handle(&self, mut req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
193        // PutObject / UploadPart enter dispatch via the streaming path
194        // with `body = Bytes::new()` and the raw HTTP body available on
195        // `req.body_stream`. Those handlers consume the stream directly
196        // — spooling chunks to disk while computing MD5 + size in
197        // constant memory — so a 1 GiB upload never materializes into
198        // RAM. Every *other* PUT-on-bucket-key the streaming dispatch
199        // flagged (PutObjectTagging, PutObjectAcl, PutObjectRetention,
200        // PutObjectLegalHold, CopyObject, …) reads a small XML/JSON
201        // body from `req.body`, so drain the stream for them.
202        let is_put_to_key = req.method == Method::PUT
203            && req.path_segments.len() >= 2
204            && req
205                .path_segments
206                .first()
207                .map(|s| !s.is_empty())
208                .unwrap_or(false);
209        let q = &req.query_params;
210        let is_put_object = is_put_to_key
211            && !q.contains_key("tagging")
212            && !q.contains_key("acl")
213            && !q.contains_key("retention")
214            && !q.contains_key("legal-hold")
215            && !q.contains_key("renameObject")
216            && !q.contains_key("encryption")
217            && !req.headers.contains_key("x-amz-copy-source");
218        // UploadPart requires both partNumber AND uploadId; checking
219        // only partNumber would skip body draining for stray PUTs that
220        // happened to carry a partNumber query param without being a
221        // real multipart upload.
222        let is_upload_part =
223            is_put_to_key && q.contains_key("partNumber") && q.contains_key("uploadId");
224        if !is_put_object && !is_upload_part {
225            if let Some(stream) = req.take_body_stream() {
226                req.body = fakecloud_core::service::drain_request_stream(stream).await?;
227            }
228        }
229
230        // S3 REST routing: method + path segments + query params
231        let bucket = req.path_segments.first().map(|s| s.as_str());
232        // Extract key from the raw path to preserve leading slashes and empty segments.
233        // The raw path is like "/bucket/key/parts" — we strip the bucket prefix.
234        let key = if let Some(b) = bucket {
235            let prefix = format!("/{b}/");
236            if req.raw_path.starts_with(&prefix) && req.raw_path.len() > prefix.len() {
237                let raw_key = &req.raw_path[prefix.len()..];
238                Some(
239                    percent_encoding::percent_decode_str(raw_key)
240                        .decode_utf8_lossy()
241                        .into_owned(),
242                )
243            } else if req.path_segments.len() > 1 {
244                let raw = req.path_segments[1..].join("/");
245                Some(
246                    percent_encoding::percent_decode_str(&raw)
247                        .decode_utf8_lossy()
248                        .into_owned(),
249                )
250            } else {
251                None
252            }
253        } else {
254            None
255        };
256
257        let account_id = req.account_id.as_str();
258
259        // Multipart upload operations (checked before main match)
260        if let Some(b) = bucket {
261            // POST /{bucket}/{key}?uploads — CreateMultipartUpload
262            if req.method == Method::POST
263                && key.is_some()
264                && req.query_params.contains_key("uploads")
265            {
266                return self.create_multipart_upload(account_id, &req, b, key.as_deref().unwrap());
267            }
268
269            // POST /{bucket}/{key}?restore
270            if req.method == Method::POST
271                && key.is_some()
272                && req.query_params.contains_key("restore")
273            {
274                return self.restore_object(account_id, &req, b, key.as_deref().unwrap());
275            }
276
277            // POST /{bucket}/{key}?uploadId=X — CompleteMultipartUpload
278            if req.method == Method::POST && key.is_some() {
279                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
280                    return self.complete_multipart_upload(
281                        account_id,
282                        &req,
283                        b,
284                        key.as_deref().unwrap(),
285                        &upload_id,
286                    );
287                }
288            }
289
290            // PUT /{bucket}/{key}?partNumber=N&uploadId=X — UploadPart or UploadPartCopy
291            if req.method == Method::PUT && key.is_some() {
292                if let (Some(part_num_str), Some(upload_id)) = (
293                    req.query_params.get("partNumber").cloned(),
294                    req.query_params.get("uploadId").cloned(),
295                ) {
296                    if let Ok(part_number) = part_num_str.parse::<i64>() {
297                        if req.headers.contains_key("x-amz-copy-source") {
298                            return self.upload_part_copy(
299                                account_id,
300                                &req,
301                                b,
302                                key.as_deref().unwrap(),
303                                &upload_id,
304                                part_number,
305                            );
306                        }
307                        return self
308                            .upload_part(
309                                account_id,
310                                &req,
311                                b,
312                                key.as_deref().unwrap(),
313                                &upload_id,
314                                part_number,
315                            )
316                            .await;
317                    }
318                }
319            }
320
321            // DELETE /{bucket}/{key}?uploadId=X — AbortMultipartUpload
322            if req.method == Method::DELETE && key.is_some() {
323                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
324                    return self.abort_multipart_upload(
325                        account_id,
326                        b,
327                        key.as_deref().unwrap(),
328                        &upload_id,
329                    );
330                }
331            }
332
333            // GET /{bucket}?uploads — ListMultipartUploads
334            if req.method == Method::GET
335                && key.is_none()
336                && req.query_params.contains_key("uploads")
337            {
338                return self.list_multipart_uploads(account_id, b);
339            }
340
341            // GET /{bucket}/{key}?uploadId=X — ListParts
342            if req.method == Method::GET && key.is_some() {
343                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
344                    return self.list_parts(
345                        account_id,
346                        &req,
347                        b,
348                        key.as_deref().unwrap(),
349                        &upload_id,
350                    );
351                }
352            }
353        }
354
355        // Handle OPTIONS preflight requests (CORS)
356        if req.method == Method::OPTIONS {
357            if let Some(b_name) = bucket {
358                let cors_config = {
359                    let accounts = self.state.read();
360                    let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
361                    let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
362                    state
363                        .buckets
364                        .get(b_name)
365                        .and_then(|b| b.cors_config.clone())
366                };
367                if let Some(ref config) = cors_config {
368                    let origin = req
369                        .headers
370                        .get("origin")
371                        .and_then(|v| v.to_str().ok())
372                        .unwrap_or("");
373                    let request_method = req
374                        .headers
375                        .get("access-control-request-method")
376                        .and_then(|v| v.to_str().ok())
377                        .unwrap_or("");
378                    let rules = parse_cors_config(config);
379                    if let Some(rule) = find_cors_rule(&rules, origin, Some(request_method)) {
380                        let mut headers = HeaderMap::new();
381                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
382                            "*"
383                        } else {
384                            origin
385                        };
386                        headers.insert(
387                            "access-control-allow-origin",
388                            matched_origin
389                                .parse()
390                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
391                        );
392                        headers.insert(
393                            "access-control-allow-methods",
394                            rule.allowed_methods
395                                .join(", ")
396                                .parse()
397                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
398                        );
399                        if !rule.allowed_headers.is_empty() {
400                            let ah = if rule.allowed_headers.contains(&"*".to_string()) {
401                                req.headers
402                                    .get("access-control-request-headers")
403                                    .and_then(|v| v.to_str().ok())
404                                    .unwrap_or("*")
405                                    .to_string()
406                            } else {
407                                rule.allowed_headers.join(", ")
408                            };
409                            headers.insert(
410                                "access-control-allow-headers",
411                                ah.parse()
412                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
413                            );
414                        }
415                        if let Some(max_age) = rule.max_age_seconds {
416                            headers.insert(
417                                "access-control-max-age",
418                                max_age
419                                    .to_string()
420                                    .parse()
421                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
422                            );
423                        }
424                        return Ok(AwsResponse {
425                            status: StatusCode::OK,
426                            content_type: String::new(),
427                            body: Bytes::new().into(),
428                            headers,
429                        });
430                    }
431                }
432                return Err(AwsServiceError::aws_error(
433                    StatusCode::FORBIDDEN,
434                    "CORSResponse",
435                    "CORS is not enabled for this bucket",
436                ));
437            }
438        }
439
440        // Capture origin for CORS response headers
441        let origin_header = req
442            .headers
443            .get("origin")
444            .and_then(|v| v.to_str().ok())
445            .map(|s| s.to_string());
446
447        let mut result = match (&req.method, bucket, key.as_deref()) {
448            // ListBuckets: GET /
449            (&Method::GET, None, None) => {
450                if req.query_params.get("x-id").map(|s| s.as_str()) == Some("ListDirectoryBuckets")
451                {
452                    self.list_directory_buckets(account_id, &req)
453                } else {
454                    self.list_buckets(account_id, &req)
455                }
456            }
457
458            // Bucket-level operations (no key)
459            (&Method::PUT, Some(b), None) => {
460                if req.query_params.contains_key("tagging") {
461                    self.put_bucket_tagging(account_id, &req, b)
462                } else if req.query_params.contains_key("acl") {
463                    self.put_bucket_acl(account_id, &req, b)
464                } else if req.query_params.contains_key("versioning") {
465                    self.put_bucket_versioning(account_id, &req, b)
466                } else if req.query_params.contains_key("cors") {
467                    self.put_bucket_cors(account_id, &req, b)
468                } else if req.query_params.contains_key("notification") {
469                    self.put_bucket_notification(account_id, &req, b)
470                } else if req.query_params.contains_key("website") {
471                    self.put_bucket_website(account_id, &req, b)
472                } else if req.query_params.contains_key("accelerate") {
473                    self.put_bucket_accelerate(account_id, &req, b)
474                } else if req.query_params.contains_key("publicAccessBlock") {
475                    self.put_public_access_block(account_id, &req, b)
476                } else if req.query_params.contains_key("encryption") {
477                    self.put_bucket_encryption(account_id, &req, b)
478                } else if req.query_params.contains_key("lifecycle") {
479                    self.put_bucket_lifecycle(account_id, &req, b)
480                } else if req.query_params.contains_key("logging") {
481                    self.put_bucket_logging(account_id, &req, b)
482                } else if req.query_params.contains_key("policy") {
483                    self.put_bucket_policy(account_id, &req, b)
484                } else if req.query_params.contains_key("object-lock") {
485                    self.put_object_lock_config(account_id, &req, b)
486                } else if req.query_params.contains_key("replication") {
487                    self.put_bucket_replication(account_id, &req, b)
488                } else if req.query_params.contains_key("ownershipControls") {
489                    self.put_bucket_ownership_controls(account_id, &req, b)
490                } else if req.query_params.contains_key("inventory") {
491                    self.put_bucket_inventory(account_id, &req, b)
492                } else if req.query_params.contains_key("analytics") {
493                    self.put_bucket_analytics_config(account_id, &req, b)
494                } else if req.query_params.contains_key("intelligent-tiering") {
495                    self.put_bucket_intelligent_tiering_config(account_id, &req, b)
496                } else if req.query_params.contains_key("metrics") {
497                    self.put_bucket_metrics_config(account_id, &req, b)
498                } else if req.query_params.contains_key("requestPayment") {
499                    self.put_bucket_request_payment(account_id, &req, b)
500                } else if req.query_params.contains_key("abac") {
501                    self.put_bucket_abac(account_id, &req, b)
502                } else if req.query_params.contains_key("metadataInventoryTable") {
503                    self.update_bucket_metadata_inventory_table(account_id, &req, b)
504                } else if req.query_params.contains_key("metadataJournalTable") {
505                    self.update_bucket_metadata_journal_table(account_id, &req, b)
506                } else {
507                    self.create_bucket(account_id, &req, b)
508                }
509            }
510            (&Method::DELETE, Some(b), None) => {
511                if req.query_params.contains_key("tagging") {
512                    self.delete_bucket_tagging(account_id, &req, b)
513                } else if req.query_params.contains_key("cors") {
514                    self.delete_bucket_cors(account_id, b)
515                } else if req.query_params.contains_key("website") {
516                    self.delete_bucket_website(account_id, b)
517                } else if req.query_params.contains_key("publicAccessBlock") {
518                    self.delete_public_access_block(account_id, b)
519                } else if req.query_params.contains_key("encryption") {
520                    self.delete_bucket_encryption(account_id, b)
521                } else if req.query_params.contains_key("lifecycle") {
522                    self.delete_bucket_lifecycle(account_id, b)
523                } else if req.query_params.contains_key("policy") {
524                    self.delete_bucket_policy(account_id, b)
525                } else if req.query_params.contains_key("replication") {
526                    self.delete_bucket_replication(account_id, b)
527                } else if req.query_params.contains_key("ownershipControls") {
528                    self.delete_bucket_ownership_controls(account_id, b)
529                } else if req.query_params.contains_key("inventory") {
530                    self.delete_bucket_inventory(account_id, &req, b)
531                } else if req.query_params.contains_key("analytics") {
532                    self.delete_bucket_analytics_config(account_id, &req, b)
533                } else if req.query_params.contains_key("intelligent-tiering") {
534                    self.delete_bucket_intelligent_tiering_config(account_id, &req, b)
535                } else if req.query_params.contains_key("metrics") {
536                    self.delete_bucket_metrics_config(account_id, &req, b)
537                } else if req.query_params.contains_key("metadataConfiguration") {
538                    self.delete_bucket_metadata_config(account_id, b)
539                } else if req.query_params.contains_key("metadataTable") {
540                    self.delete_bucket_metadata_table_config(account_id, b)
541                } else {
542                    self.delete_bucket(account_id, &req, b)
543                }
544            }
545            (&Method::HEAD, Some(b), None) => self.head_bucket(account_id, b),
546            (&Method::GET, Some(b), None) => {
547                if req.query_params.contains_key("tagging") {
548                    self.get_bucket_tagging(account_id, &req, b)
549                } else if req.query_params.contains_key("location") {
550                    self.get_bucket_location(account_id, b)
551                } else if req.query_params.contains_key("acl") {
552                    self.get_bucket_acl(account_id, &req, b)
553                } else if req.query_params.contains_key("versioning") {
554                    self.get_bucket_versioning(account_id, b)
555                } else if req.query_params.contains_key("versions") {
556                    self.list_object_versions(account_id, &req, b)
557                } else if req.query_params.contains_key("object-lock") {
558                    self.get_object_lock_configuration(account_id, b)
559                } else if req.query_params.contains_key("cors") {
560                    self.get_bucket_cors(account_id, b)
561                } else if req.query_params.contains_key("notification") {
562                    self.get_bucket_notification(account_id, b)
563                } else if req.query_params.contains_key("website") {
564                    self.get_bucket_website(account_id, b)
565                } else if req.query_params.contains_key("accelerate") {
566                    self.get_bucket_accelerate(account_id, b)
567                } else if req.query_params.contains_key("publicAccessBlock") {
568                    self.get_public_access_block(account_id, b)
569                } else if req.query_params.contains_key("encryption") {
570                    self.get_bucket_encryption(account_id, b)
571                } else if req.query_params.contains_key("lifecycle") {
572                    self.get_bucket_lifecycle(account_id, b)
573                } else if req.query_params.contains_key("logging") {
574                    self.get_bucket_logging(account_id, b)
575                } else if req.query_params.contains_key("policy") {
576                    self.get_bucket_policy(account_id, b)
577                } else if req.query_params.contains_key("replication") {
578                    self.get_bucket_replication(account_id, b)
579                } else if req.query_params.contains_key("ownershipControls") {
580                    self.get_bucket_ownership_controls(account_id, b)
581                } else if req.query_params.contains_key("inventory") {
582                    if req.query_params.contains_key("id") {
583                        self.get_bucket_inventory(account_id, &req, b)
584                    } else {
585                        self.list_bucket_inventory_configurations(account_id, b)
586                    }
587                } else if req.query_params.contains_key("analytics") {
588                    if req.query_params.contains_key("id") {
589                        self.get_bucket_analytics_config(account_id, &req, b)
590                    } else {
591                        self.list_bucket_analytics_configurations(account_id, b)
592                    }
593                } else if req.query_params.contains_key("intelligent-tiering") {
594                    if req.query_params.contains_key("id") {
595                        self.get_bucket_intelligent_tiering_config(account_id, &req, b)
596                    } else {
597                        self.list_bucket_intelligent_tiering_configurations(account_id, b)
598                    }
599                } else if req.query_params.contains_key("metrics") {
600                    if req.query_params.contains_key("id") {
601                        self.get_bucket_metrics_config(account_id, &req, b)
602                    } else {
603                        self.list_bucket_metrics_configurations(account_id, b)
604                    }
605                } else if req.query_params.contains_key("requestPayment") {
606                    self.get_bucket_request_payment(account_id, b)
607                } else if req.query_params.contains_key("abac") {
608                    self.get_bucket_abac(account_id, b)
609                } else if req.query_params.contains_key("policyStatus") {
610                    self.get_bucket_policy_status(account_id, b)
611                } else if req.query_params.contains_key("metadataConfiguration") {
612                    self.get_bucket_metadata_config(account_id, b)
613                } else if req.query_params.contains_key("metadataTable") {
614                    self.get_bucket_metadata_table_config(account_id, b)
615                } else if req.query_params.contains_key("session") {
616                    self.create_session(account_id, &req, b)
617                } else if req.query_params.get("list-type").map(|s| s.as_str()) == Some("2") {
618                    self.list_objects_v2(account_id, &req, b)
619                } else if req.query_params.is_empty() {
620                    // If bucket has website config and no query params, serve index document
621                    let website_config = {
622                        let accounts = self.state.read();
623                        let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
624                        let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
625                        state
626                            .buckets
627                            .get(b)
628                            .and_then(|bkt| bkt.website_config.clone())
629                    };
630                    if let Some(ref config) = website_config {
631                        if let Some(index_doc) = extract_xml_value(config, "Suffix").or_else(|| {
632                            extract_xml_value(config, "IndexDocument").and_then(|inner| {
633                                let open = "<Suffix>";
634                                let close = "</Suffix>";
635                                let s = inner.find(open)? + open.len();
636                                let e = inner.find(close)?;
637                                Some(inner[s..e].trim().to_string())
638                            })
639                        }) {
640                            self.serve_website_object(account_id, &req, b, &index_doc, config)
641                        } else {
642                            self.list_objects_v1(account_id, &req, b)
643                        }
644                    } else {
645                        self.list_objects_v1(account_id, &req, b)
646                    }
647                } else {
648                    self.list_objects_v1(account_id, &req, b)
649                }
650            }
651
652            // Object-level operations
653            (&Method::PUT, Some(b), Some(k)) => {
654                if req.query_params.contains_key("tagging") {
655                    self.put_object_tagging(account_id, &req, b, k)
656                } else if req.query_params.contains_key("acl") {
657                    self.put_object_acl(account_id, &req, b, k)
658                } else if req.query_params.contains_key("retention") {
659                    self.put_object_retention(account_id, &req, b, k)
660                } else if req.query_params.contains_key("legal-hold") {
661                    self.put_object_legal_hold(account_id, &req, b, k)
662                } else if req.query_params.contains_key("renameObject") {
663                    self.rename_object(account_id, &req, b, k)
664                } else if req.query_params.contains_key("encryption") {
665                    self.update_object_encryption(account_id, &req, b, k)
666                } else if req.headers.contains_key("x-amz-copy-source") {
667                    self.copy_object(account_id, &req, b, k)
668                } else {
669                    self.put_object(account_id, &req, b, k).await
670                }
671            }
672            (&Method::GET, Some(b), Some(k)) => {
673                if req.query_params.contains_key("tagging") {
674                    self.get_object_tagging(account_id, &req, b, k)
675                } else if req.query_params.contains_key("acl") {
676                    self.get_object_acl(account_id, &req, b, k)
677                } else if req.query_params.contains_key("retention") {
678                    self.get_object_retention(account_id, &req, b, k)
679                } else if req.query_params.contains_key("legal-hold") {
680                    self.get_object_legal_hold(account_id, &req, b, k)
681                } else if req.query_params.contains_key("attributes") {
682                    self.get_object_attributes(account_id, &req, b, k)
683                } else if req.query_params.contains_key("torrent") {
684                    self.get_object_torrent(account_id, &req, b, k)
685                } else {
686                    let result = self.get_object(account_id, &req, b, k);
687                    // If object not found and bucket has website config, serve error document
688                    let is_not_found = matches!(
689                        &result,
690                        Err(e) if e.code() == "NoSuchKey"
691                    );
692                    if is_not_found {
693                        let website_config = {
694                            let accounts = self.state.read();
695                            let _empty_s3 =
696                                crate::state::S3State::new(&req.account_id, &req.region);
697                            let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
698                            state
699                                .buckets
700                                .get(b)
701                                .and_then(|bkt| bkt.website_config.clone())
702                        };
703                        if let Some(ref config) = website_config {
704                            if let Some(error_key) = extract_xml_value(config, "ErrorDocument")
705                                .and_then(|inner| {
706                                    let open = "<Key>";
707                                    let close = "</Key>";
708                                    let s = inner.find(open)? + open.len();
709                                    let e = inner.find(close)?;
710                                    Some(inner[s..e].trim().to_string())
711                                })
712                                .or_else(|| extract_xml_value(config, "Key"))
713                            {
714                                return self.serve_website_error(account_id, &req, b, &error_key);
715                            }
716                        }
717                    }
718                    result
719                }
720            }
721            (&Method::DELETE, Some(b), Some(k)) => {
722                if req.query_params.contains_key("tagging") {
723                    self.delete_object_tagging(account_id, b, k)
724                } else {
725                    self.delete_object(account_id, &req, b, k)
726                }
727            }
728            (&Method::HEAD, Some(b), Some(k)) => self.head_object(account_id, &req, b, k),
729
730            // POST /{bucket}?delete — batch delete
731            (&Method::POST, Some(b), None) if req.query_params.contains_key("delete") => {
732                self.delete_objects(account_id, &req, b)
733            }
734            (&Method::POST, Some(b), None)
735                if req.query_params.contains_key("metadataConfiguration") =>
736            {
737                self.create_bucket_metadata_config(account_id, &req, b)
738            }
739            (&Method::POST, Some(b), None) if req.query_params.contains_key("metadataTable") => {
740                self.create_bucket_metadata_table_config(account_id, &req, b)
741            }
742            (&Method::POST, Some(b), Some(k))
743                if req.query_params.get("select-type").map(|s| s.as_str()) == Some("2") =>
744            {
745                self.select_object_content(account_id, &req, b, k)
746            }
747            (&Method::POST, Some("WriteGetObjectResponse"), None) => {
748                self.write_get_object_response(account_id, &req)
749            }
750
751            _ => Err(AwsServiceError::aws_error(
752                StatusCode::METHOD_NOT_ALLOWED,
753                "MethodNotAllowed",
754                "The specified method is not allowed against this resource",
755            )),
756        };
757
758        // Apply CORS headers to the response if Origin was present
759        if let (Some(ref origin), Some(b_name)) = (&origin_header, bucket) {
760            let cors_config = {
761                let accounts = self.state.read();
762                let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
763                let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
764                state
765                    .buckets
766                    .get(b_name)
767                    .and_then(|b| b.cors_config.clone())
768            };
769            if let Some(ref config) = cors_config {
770                let rules = parse_cors_config(config);
771                if let Some(rule) = find_cors_rule(&rules, origin, None) {
772                    if let Ok(ref mut resp) = result {
773                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
774                            "*"
775                        } else {
776                            origin
777                        };
778                        resp.headers.insert(
779                            "access-control-allow-origin",
780                            matched_origin
781                                .parse()
782                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
783                        );
784                        if !rule.expose_headers.is_empty() {
785                            resp.headers.insert(
786                                "access-control-expose-headers",
787                                rule.expose_headers
788                                    .join(", ")
789                                    .parse()
790                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
791                            );
792                        }
793                    }
794                }
795            }
796        }
797
798        // Write S3 access log entry if the source bucket has logging enabled
799        if let Some(b_name) = bucket {
800            let status_code = match &result {
801                Ok(resp) => resp.status.as_u16(),
802                Err(e) => e.status().as_u16(),
803            };
804            let op = logging::operation_name(&req.method, key.as_deref());
805            logging::maybe_write_access_log(
806                &self.state,
807                &self.store,
808                b_name,
809                &logging::AccessLogRequest {
810                    operation: op,
811                    key: key.as_deref(),
812                    status: status_code,
813                    request_id: &req.request_id,
814                    method: req.method.as_str(),
815                    path: &req.raw_path,
816                },
817            );
818        }
819
820        result
821    }
822
823    fn supported_actions(&self) -> &[&str] {
824        &[
825            // Buckets
826            "ListBuckets",
827            "CreateBucket",
828            "DeleteBucket",
829            "HeadBucket",
830            "GetBucketLocation",
831            // Objects
832            "PutObject",
833            "GetObject",
834            "DeleteObject",
835            "HeadObject",
836            "CopyObject",
837            "DeleteObjects",
838            "ListObjectsV2",
839            "ListObjects",
840            "ListObjectVersions",
841            "GetObjectAttributes",
842            "RestoreObject",
843            // Object properties
844            "PutObjectTagging",
845            "GetObjectTagging",
846            "DeleteObjectTagging",
847            "PutObjectAcl",
848            "GetObjectAcl",
849            "PutObjectRetention",
850            "GetObjectRetention",
851            "PutObjectLegalHold",
852            "GetObjectLegalHold",
853            // Bucket configuration
854            "PutBucketTagging",
855            "GetBucketTagging",
856            "DeleteBucketTagging",
857            "PutBucketAcl",
858            "GetBucketAcl",
859            "PutBucketVersioning",
860            "GetBucketVersioning",
861            "PutBucketCors",
862            "GetBucketCors",
863            "DeleteBucketCors",
864            "PutBucketNotificationConfiguration",
865            "GetBucketNotificationConfiguration",
866            "PutBucketWebsite",
867            "GetBucketWebsite",
868            "DeleteBucketWebsite",
869            "PutBucketAccelerateConfiguration",
870            "GetBucketAccelerateConfiguration",
871            "PutPublicAccessBlock",
872            "GetPublicAccessBlock",
873            "DeletePublicAccessBlock",
874            "PutBucketEncryption",
875            "GetBucketEncryption",
876            "DeleteBucketEncryption",
877            "PutBucketLifecycleConfiguration",
878            "GetBucketLifecycleConfiguration",
879            "DeleteBucketLifecycle",
880            "PutBucketLogging",
881            "GetBucketLogging",
882            "PutBucketPolicy",
883            "GetBucketPolicy",
884            "DeleteBucketPolicy",
885            "PutObjectLockConfiguration",
886            "GetObjectLockConfiguration",
887            "PutBucketReplication",
888            "GetBucketReplication",
889            "DeleteBucketReplication",
890            "PutBucketOwnershipControls",
891            "GetBucketOwnershipControls",
892            "DeleteBucketOwnershipControls",
893            "PutBucketInventoryConfiguration",
894            "GetBucketInventoryConfiguration",
895            "DeleteBucketInventoryConfiguration",
896            "ListBucketInventoryConfigurations",
897            "PutBucketAnalyticsConfiguration",
898            "GetBucketAnalyticsConfiguration",
899            "DeleteBucketAnalyticsConfiguration",
900            "ListBucketAnalyticsConfigurations",
901            "PutBucketIntelligentTieringConfiguration",
902            "GetBucketIntelligentTieringConfiguration",
903            "DeleteBucketIntelligentTieringConfiguration",
904            "ListBucketIntelligentTieringConfigurations",
905            "PutBucketMetricsConfiguration",
906            "GetBucketMetricsConfiguration",
907            "DeleteBucketMetricsConfiguration",
908            "ListBucketMetricsConfigurations",
909            "PutBucketRequestPayment",
910            "GetBucketRequestPayment",
911            "PutBucketAbac",
912            "GetBucketAbac",
913            "GetBucketPolicyStatus",
914            "CreateBucketMetadataConfiguration",
915            "GetBucketMetadataConfiguration",
916            "DeleteBucketMetadataConfiguration",
917            "CreateBucketMetadataTableConfiguration",
918            "GetBucketMetadataTableConfiguration",
919            "DeleteBucketMetadataTableConfiguration",
920            "UpdateBucketMetadataInventoryTableConfiguration",
921            "UpdateBucketMetadataJournalTableConfiguration",
922            "GetObjectTorrent",
923            "RenameObject",
924            "SelectObjectContent",
925            "UpdateObjectEncryption",
926            "WriteGetObjectResponse",
927            "ListDirectoryBuckets",
928            "CreateSession",
929            // Multipart uploads
930            "CreateMultipartUpload",
931            "UploadPart",
932            "UploadPartCopy",
933            "CompleteMultipartUpload",
934            "AbortMultipartUpload",
935            "ListParts",
936            "ListMultipartUploads",
937        ]
938    }
939
940    fn iam_enforceable(&self) -> bool {
941        true
942    }
943
944    /// S3 resources are either:
945    /// - Bucket ARN (`arn:aws:s3:::bucket`) for bucket-level actions
946    /// - Object ARN (`arn:aws:s3:::bucket/key`) for object-level actions
947    /// - Wildcard (`*`) for `ListBuckets` which doesn't target a specific
948    ///   resource
949    ///
950    /// S3 ARNs notably omit the account id and region — this is the one
951    /// AWS service that carries neither in its ARN, because bucket names
952    /// are globally unique.
953    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
954        // S3 doesn't set `request.action` — the handler dispatches on
955        // method + path + query params directly. Re-derive the action
956        // name here so enforcement can match against IAM policies the
957        // same way the real service would.
958        let bucket = request.path_segments.first().map(|s| s.as_str());
959        let key = if request.path_segments.len() > 1 {
960            Some(request.path_segments[1..].join("/"))
961        } else {
962            None
963        };
964        let action = s3_detect_action(
965            request.method.as_str(),
966            bucket,
967            key.as_deref(),
968            &request.query_params,
969        )?;
970        let resource = s3_resource_for(action, bucket, key.as_deref());
971        Some(fakecloud_core::auth::IamAction {
972            service: "s3",
973            action,
974            resource,
975        })
976    }
977
978    fn iam_condition_keys_for(
979        &self,
980        request: &AwsRequest,
981        action: &fakecloud_core::auth::IamAction,
982    ) -> std::collections::BTreeMap<String, Vec<String>> {
983        s3_condition_keys(action.action, &request.query_params)
984    }
985
986    fn resource_tags_for(
987        &self,
988        resource_arn: &str,
989    ) -> Option<std::collections::HashMap<String, String>> {
990        s3_resource_tags(&self.state, resource_arn)
991    }
992
993    fn request_tags_from(
994        &self,
995        request: &AwsRequest,
996        action: &str,
997    ) -> Option<std::collections::HashMap<String, String>> {
998        s3_request_tags(request, action)
999    }
1000}
1001
1002/// Extract service-specific IAM condition keys from an S3 request.
1003///
1004/// Today only `ListObjects` / `ListObjectsV2` expose keys (`s3:prefix`,
1005/// `s3:delimiter`, `s3:max-keys`) via their query params. Other actions
1006/// return an empty map so the evaluator's safe-fail semantics treat any
1007/// policy condition referencing an unknown key as "doesn't apply".
1008fn s3_condition_keys(
1009    action: &str,
1010    query: &std::collections::HashMap<String, String>,
1011) -> std::collections::BTreeMap<String, Vec<String>> {
1012    let mut out = std::collections::BTreeMap::new();
1013    if matches!(action, "ListObjects" | "ListObjectsV2") {
1014        // Both list variants share the same query param shape.
1015        if let Some(prefix) = query.get("prefix") {
1016            out.insert("s3:prefix".to_string(), vec![prefix.clone()]);
1017        }
1018        if let Some(delimiter) = query.get("delimiter") {
1019            out.insert("s3:delimiter".to_string(), vec![delimiter.clone()]);
1020        }
1021        if let Some(max_keys) = query.get("max-keys") {
1022            out.insert("s3:max-keys".to_string(), vec![max_keys.clone()]);
1023        }
1024    }
1025    out
1026}
1027
1028/// Look up resource tags for an S3 ARN.
1029///
1030/// Bucket-level ARN (`arn:aws:s3:::bucket`) -> bucket tags.
1031/// Object-level ARN (`arn:aws:s3:::bucket/key`) -> object tags.
1032/// `*` (ListBuckets) -> `Some(empty)` (no resource to tag).
1033fn s3_resource_tags(
1034    state: &SharedS3State,
1035    resource_arn: &str,
1036) -> Option<std::collections::HashMap<String, String>> {
1037    if resource_arn == "*" {
1038        return Some(std::collections::HashMap::new());
1039    }
1040    // S3 ARNs: arn:aws:s3:::bucket or arn:aws:s3:::bucket/key
1041    let after_prefix = resource_arn.strip_prefix("arn:aws:s3:::")?;
1042    let mas = state.read();
1043    // S3 bucket names are globally unique; scan all accounts to find the bucket
1044    let bucket_name = after_prefix.split('/').next().unwrap_or(after_prefix);
1045    let state = mas
1046        .find_account(|s| s.buckets.contains_key(bucket_name))
1047        .and_then(|id| mas.get(id))
1048        .or_else(|| Some(mas.default_ref()))?;
1049    if let Some(slash_pos) = after_prefix.find('/') {
1050        // Object-level: bucket/key
1051        let bucket_name = &after_prefix[..slash_pos];
1052        let key = &after_prefix[slash_pos + 1..];
1053        let bucket = state.buckets.get(bucket_name)?;
1054        // Try current objects first, then versioned objects (latest version)
1055        if let Some(obj) = bucket.objects.get(key) {
1056            Some(obj.tags.clone())
1057        } else if let Some(versions) = bucket.object_versions.get(key) {
1058            versions.last().map(|v| v.tags.clone())
1059        } else {
1060            // Object doesn't exist yet (e.g. PutObject creating it)
1061            Some(std::collections::HashMap::new())
1062        }
1063    } else {
1064        // Bucket-level
1065        let bucket = state.buckets.get(after_prefix)?;
1066        Some(bucket.tags.clone())
1067    }
1068}
1069
1070/// Extract tags from an S3 request body/headers.
1071///
1072/// S3 sends tags via:
1073/// - `x-amz-tagging` header (URL-encoded `key=value&...`) on PutObject
1074/// - XML body on PutBucketTagging / PutObjectTagging
1075fn s3_request_tags(
1076    request: &AwsRequest,
1077    action: &str,
1078) -> Option<std::collections::HashMap<String, String>> {
1079    match action {
1080        "PutObject" | "CopyObject" | "CreateMultipartUpload" => {
1081            // Tags come via x-amz-tagging header
1082            if let Some(tagging) = request.headers.get("x-amz-tagging") {
1083                let tags = parse_url_encoded_tags(tagging.to_str().unwrap_or(""));
1084                Some(tags.into_iter().collect())
1085            } else {
1086                Some(std::collections::HashMap::new())
1087            }
1088        }
1089        "PutBucketTagging" | "PutObjectTagging" => {
1090            // Tags come in XML body
1091            let body = std::str::from_utf8(&request.body).unwrap_or("");
1092            let tags = parse_tagging_xml(body);
1093            Some(tags.into_iter().collect())
1094        }
1095        _ => Some(std::collections::HashMap::new()),
1096    }
1097}
1098
1099/// Derive the IAM action name from an S3 REST request. Handles the
1100/// common cases (GetObject, PutObject, DeleteObject, ListObjectsV2,
1101/// CreateBucket, ...) plus a subset of sub-resource operations
1102/// (`?acl`, `?tagging`, `?versioning`, `?policy`, `?cors`, `?website`,
1103/// `?lifecycle`, `?encryption`, `?logging`, `?notification`, `?replication`,
1104/// `?ownershipControls`, `?publicAccessBlock`, `?accelerate`, `?inventory`,
1105/// `?object-lock`, `?uploads`, `?uploadId`).
1106///
1107/// Returns `None` for requests that don't map to a known action — the
1108/// dispatch layer then skips enforcement for that request rather than
1109/// guessing (and a warn log fires via the "service is iam_enforceable
1110/// but has no mapping" branch in dispatch.rs).
1111fn s3_detect_action(
1112    method: &str,
1113    bucket: Option<&str>,
1114    key: Option<&str>,
1115    query: &std::collections::HashMap<String, String>,
1116) -> Option<&'static str> {
1117    let has = |q: &str| query.contains_key(q);
1118    let is_get = method == "GET";
1119    let is_put = method == "PUT";
1120    let is_post = method == "POST";
1121    let is_delete = method == "DELETE";
1122
1123    // Service root
1124    if bucket.is_none() {
1125        return match method {
1126            "GET" => Some("ListBuckets"),
1127            _ => None,
1128        };
1129    }
1130    let has_key = key.is_some();
1131
1132    // Multipart sub-resource forms
1133    if has_key && is_post && has("uploads") {
1134        return Some("CreateMultipartUpload");
1135    }
1136    if has_key && is_post && has("uploadId") {
1137        return Some("CompleteMultipartUpload");
1138    }
1139    if has_key && is_put && has("partNumber") && has("uploadId") {
1140        return Some("UploadPart");
1141    }
1142    if has_key && is_delete && has("uploadId") {
1143        return Some("AbortMultipartUpload");
1144    }
1145    if has_key && is_get && has("uploadId") {
1146        return Some("ListParts");
1147    }
1148    if !has_key && is_get && has("uploads") {
1149        return Some("ListMultipartUploads");
1150    }
1151
1152    // Sub-resource-keyed actions (?acl, ?tagging, ...). Order matters
1153    // since a request can carry multiple; we pick the most specific.
1154    // Object-level sub-resources come first (key present).
1155    if has_key {
1156        if has("tagging") {
1157            return Some(match method {
1158                "GET" => "GetObjectTagging",
1159                "PUT" => "PutObjectTagging",
1160                "DELETE" => "DeleteObjectTagging",
1161                _ => return None,
1162            });
1163        }
1164        if has("acl") {
1165            return Some(match method {
1166                "GET" => "GetObjectAcl",
1167                "PUT" => "PutObjectAcl",
1168                _ => return None,
1169            });
1170        }
1171        if has("retention") {
1172            return Some(match method {
1173                "GET" => "GetObjectRetention",
1174                "PUT" => "PutObjectRetention",
1175                _ => return None,
1176            });
1177        }
1178        if has("legal-hold") {
1179            return Some(match method {
1180                "GET" => "GetObjectLegalHold",
1181                "PUT" => "PutObjectLegalHold",
1182                _ => return None,
1183            });
1184        }
1185        // Identified by cubic on PR #399: both ?attributes and ?restore
1186        // need method guards — otherwise e.g. GET /bucket/key?restore
1187        // would be classified as RestoreObject (POST-only in AWS) and
1188        // IAM-evaluated against s3:RestoreObject instead of s3:GetObject.
1189        if has("attributes") && is_get {
1190            return Some("GetObjectAttributes");
1191        }
1192        if has("restore") && is_post {
1193            return Some("RestoreObject");
1194        }
1195    }
1196
1197    // Bucket-level sub-resources (key absent).
1198    if !has_key {
1199        if has("tagging") {
1200            return Some(match method {
1201                "GET" => "GetBucketTagging",
1202                "PUT" => "PutBucketTagging",
1203                "DELETE" => "DeleteBucketTagging",
1204                _ => return None,
1205            });
1206        }
1207        if has("acl") {
1208            return Some(match method {
1209                "GET" => "GetBucketAcl",
1210                "PUT" => "PutBucketAcl",
1211                _ => return None,
1212            });
1213        }
1214        if has("versioning") {
1215            return Some(match method {
1216                "GET" => "GetBucketVersioning",
1217                "PUT" => "PutBucketVersioning",
1218                _ => return None,
1219            });
1220        }
1221        if has("cors") {
1222            return Some(match method {
1223                "GET" => "GetBucketCors",
1224                "PUT" => "PutBucketCors",
1225                "DELETE" => "DeleteBucketCors",
1226                _ => return None,
1227            });
1228        }
1229        if has("policy") {
1230            return Some(match method {
1231                "GET" => "GetBucketPolicy",
1232                "PUT" => "PutBucketPolicy",
1233                "DELETE" => "DeleteBucketPolicy",
1234                _ => return None,
1235            });
1236        }
1237        if has("website") {
1238            return Some(match method {
1239                "GET" => "GetBucketWebsite",
1240                "PUT" => "PutBucketWebsite",
1241                "DELETE" => "DeleteBucketWebsite",
1242                _ => return None,
1243            });
1244        }
1245        if has("lifecycle") {
1246            return Some(match method {
1247                "GET" => "GetBucketLifecycleConfiguration",
1248                "PUT" => "PutBucketLifecycleConfiguration",
1249                "DELETE" => "DeleteBucketLifecycle",
1250                _ => return None,
1251            });
1252        }
1253        if has("encryption") {
1254            return Some(match method {
1255                "GET" => "GetBucketEncryption",
1256                "PUT" => "PutBucketEncryption",
1257                "DELETE" => "DeleteBucketEncryption",
1258                _ => return None,
1259            });
1260        }
1261        if has("logging") {
1262            return Some(match method {
1263                "GET" => "GetBucketLogging",
1264                "PUT" => "PutBucketLogging",
1265                _ => return None,
1266            });
1267        }
1268        if has("notification") {
1269            return Some(match method {
1270                "GET" => "GetBucketNotificationConfiguration",
1271                "PUT" => "PutBucketNotificationConfiguration",
1272                _ => return None,
1273            });
1274        }
1275        if has("replication") {
1276            return Some(match method {
1277                "GET" => "GetBucketReplication",
1278                "PUT" => "PutBucketReplication",
1279                "DELETE" => "DeleteBucketReplication",
1280                _ => return None,
1281            });
1282        }
1283        if has("ownershipControls") {
1284            return Some(match method {
1285                "GET" => "GetBucketOwnershipControls",
1286                "PUT" => "PutBucketOwnershipControls",
1287                "DELETE" => "DeleteBucketOwnershipControls",
1288                _ => return None,
1289            });
1290        }
1291        if has("publicAccessBlock") {
1292            return Some(match method {
1293                "GET" => "GetPublicAccessBlock",
1294                "PUT" => "PutPublicAccessBlock",
1295                "DELETE" => "DeletePublicAccessBlock",
1296                _ => return None,
1297            });
1298        }
1299        if has("accelerate") {
1300            return Some(match method {
1301                "GET" => "GetBucketAccelerateConfiguration",
1302                "PUT" => "PutBucketAccelerateConfiguration",
1303                _ => return None,
1304            });
1305        }
1306        if has("inventory") {
1307            return Some(match method {
1308                "GET" => "GetBucketInventoryConfiguration",
1309                "PUT" => "PutBucketInventoryConfiguration",
1310                "DELETE" => "DeleteBucketInventoryConfiguration",
1311                _ => return None,
1312            });
1313        }
1314        if has("analytics") {
1315            return Some(match method {
1316                "GET" if has("id") => "GetBucketAnalyticsConfiguration",
1317                "GET" => "ListBucketAnalyticsConfigurations",
1318                "PUT" => "PutBucketAnalyticsConfiguration",
1319                "DELETE" => "DeleteBucketAnalyticsConfiguration",
1320                _ => return None,
1321            });
1322        }
1323        if has("intelligent-tiering") {
1324            return Some(match method {
1325                "GET" if has("id") => "GetBucketIntelligentTieringConfiguration",
1326                "GET" => "ListBucketIntelligentTieringConfigurations",
1327                "PUT" => "PutBucketIntelligentTieringConfiguration",
1328                "DELETE" => "DeleteBucketIntelligentTieringConfiguration",
1329                _ => return None,
1330            });
1331        }
1332        if has("metrics") {
1333            return Some(match method {
1334                "GET" if has("id") => "GetBucketMetricsConfiguration",
1335                "GET" => "ListBucketMetricsConfigurations",
1336                "PUT" => "PutBucketMetricsConfiguration",
1337                "DELETE" => "DeleteBucketMetricsConfiguration",
1338                _ => return None,
1339            });
1340        }
1341        if has("requestPayment") {
1342            return Some(match method {
1343                "GET" => "GetBucketRequestPayment",
1344                "PUT" => "PutBucketRequestPayment",
1345                _ => return None,
1346            });
1347        }
1348        if has("policyStatus") && is_get {
1349            return Some("GetBucketPolicyStatus");
1350        }
1351        if has("metadataConfiguration") {
1352            return Some(match method {
1353                "GET" => "GetBucketMetadataConfiguration",
1354                "POST" => "CreateBucketMetadataConfiguration",
1355                "DELETE" => "DeleteBucketMetadataConfiguration",
1356                _ => return None,
1357            });
1358        }
1359        if has("metadataTable") {
1360            return Some(match method {
1361                "GET" => "GetBucketMetadataTableConfiguration",
1362                "POST" => "CreateBucketMetadataTableConfiguration",
1363                "DELETE" => "DeleteBucketMetadataTableConfiguration",
1364                _ => return None,
1365            });
1366        }
1367        if has("metadataInventoryTable") && is_put {
1368            return Some("UpdateBucketMetadataInventoryTableConfiguration");
1369        }
1370        if has("metadataJournalTable") && is_put {
1371            return Some("UpdateBucketMetadataJournalTableConfiguration");
1372        }
1373        if has("abac") && is_put {
1374            return Some("PutBucketAbacConfiguration");
1375        }
1376        if has("renameObject") && is_put {
1377            return Some("RenameObject");
1378        }
1379        if has("object-lock") {
1380            return Some(match method {
1381                "GET" => "GetObjectLockConfiguration",
1382                "PUT" => "PutObjectLockConfiguration",
1383                _ => return None,
1384            });
1385        }
1386        if has("location") {
1387            return Some("GetBucketLocation");
1388        }
1389        if is_post && has("delete") {
1390            return Some("DeleteObjects");
1391        }
1392        if is_get && has("versions") {
1393            return Some("ListObjectVersions");
1394        }
1395    }
1396
1397    // Plain bucket/object methods.
1398    match (method, has_key) {
1399        ("GET", true) => Some("GetObject"),
1400        ("PUT", true) => {
1401            // CopyObject uses x-amz-copy-source but we don't have headers
1402            // handy here — treat both PutObject and CopyObject as PutObject
1403            // for IAM purposes; CopyObject additionally requires
1404            // s3:GetObject on the source but that's evaluated per-request
1405            // by real AWS, not on the PUT call itself.
1406            Some("PutObject")
1407        }
1408        ("DELETE", true) => Some("DeleteObject"),
1409        ("HEAD", true) => Some("HeadObject"),
1410        ("GET", false) => {
1411            if query.contains_key("list-type") {
1412                Some("ListObjectsV2")
1413            } else {
1414                Some("ListObjects")
1415            }
1416        }
1417        ("PUT", false) => Some("CreateBucket"),
1418        ("DELETE", false) => Some("DeleteBucket"),
1419        ("HEAD", false) => Some("HeadBucket"),
1420        _ => None,
1421    }
1422}
1423
1424/// Full list of S3 actions whose resource ARNs are classified by
1425/// [`s3_resource_for`]. Not referenced at runtime (S3's action name is
1426/// derived from method + path in [`s3_detect_action`]), but kept as a
1427/// documented inventory so future work can easily enumerate the
1428/// enforcement surface.
1429#[allow(dead_code)]
1430const S3_SUPPORTED_ACTIONS: &[&str] = &[
1431    "ListBuckets",
1432    "CreateBucket",
1433    "DeleteBucket",
1434    "HeadBucket",
1435    "GetBucketLocation",
1436    "PutObject",
1437    "GetObject",
1438    "DeleteObject",
1439    "HeadObject",
1440    "CopyObject",
1441    "DeleteObjects",
1442    "ListObjectsV2",
1443    "ListObjects",
1444    "ListObjectVersions",
1445    "GetObjectAttributes",
1446    "RestoreObject",
1447    "PutObjectTagging",
1448    "GetObjectTagging",
1449    "DeleteObjectTagging",
1450    "PutObjectAcl",
1451    "GetObjectAcl",
1452    "PutObjectRetention",
1453    "GetObjectRetention",
1454    "PutObjectLegalHold",
1455    "GetObjectLegalHold",
1456    "PutBucketTagging",
1457    "GetBucketTagging",
1458    "DeleteBucketTagging",
1459    "PutBucketAcl",
1460    "GetBucketAcl",
1461    "PutBucketVersioning",
1462    "GetBucketVersioning",
1463    "PutBucketCors",
1464    "GetBucketCors",
1465    "DeleteBucketCors",
1466    "PutBucketNotificationConfiguration",
1467    "GetBucketNotificationConfiguration",
1468    "PutBucketWebsite",
1469    "GetBucketWebsite",
1470    "DeleteBucketWebsite",
1471    "PutBucketAccelerateConfiguration",
1472    "GetBucketAccelerateConfiguration",
1473    "PutPublicAccessBlock",
1474    "GetPublicAccessBlock",
1475    "DeletePublicAccessBlock",
1476    "PutBucketEncryption",
1477    "GetBucketEncryption",
1478    "DeleteBucketEncryption",
1479    "PutBucketLifecycleConfiguration",
1480    "GetBucketLifecycleConfiguration",
1481    "DeleteBucketLifecycle",
1482    "PutBucketLogging",
1483    "GetBucketLogging",
1484    "PutBucketPolicy",
1485    "GetBucketPolicy",
1486    "DeleteBucketPolicy",
1487    "PutObjectLockConfiguration",
1488    "GetObjectLockConfiguration",
1489    "PutBucketReplication",
1490    "GetBucketReplication",
1491    "DeleteBucketReplication",
1492    "PutBucketOwnershipControls",
1493    "GetBucketOwnershipControls",
1494    "DeleteBucketOwnershipControls",
1495    "PutBucketInventoryConfiguration",
1496    "GetBucketInventoryConfiguration",
1497    "DeleteBucketInventoryConfiguration",
1498    "CreateMultipartUpload",
1499    "UploadPart",
1500    "UploadPartCopy",
1501    "CompleteMultipartUpload",
1502    "AbortMultipartUpload",
1503    "ListParts",
1504    "ListMultipartUploads",
1505];
1506
1507/// Build the S3 resource ARN for an action. Returns `*` for
1508/// `ListBuckets` (account-scoped), a bucket ARN for bucket-level
1509/// configuration actions, or an object ARN for object-level actions.
1510fn s3_resource_for(action: &'static str, bucket: Option<&str>, key: Option<&str>) -> String {
1511    // Object-level actions work on `bucket/key`.
1512    const OBJECT_ACTIONS: &[&str] = &[
1513        "PutObject",
1514        "GetObject",
1515        "DeleteObject",
1516        "HeadObject",
1517        "CopyObject",
1518        "GetObjectAttributes",
1519        "RestoreObject",
1520        "PutObjectTagging",
1521        "GetObjectTagging",
1522        "DeleteObjectTagging",
1523        "PutObjectAcl",
1524        "GetObjectAcl",
1525        "PutObjectRetention",
1526        "GetObjectRetention",
1527        "PutObjectLegalHold",
1528        "GetObjectLegalHold",
1529        "CreateMultipartUpload",
1530        "UploadPart",
1531        "UploadPartCopy",
1532        "CompleteMultipartUpload",
1533        "AbortMultipartUpload",
1534        "ListParts",
1535    ];
1536    if action == "ListBuckets" {
1537        return "*".to_string();
1538    }
1539    let Some(bucket) = bucket else {
1540        return "*".to_string();
1541    };
1542    if OBJECT_ACTIONS.contains(&action) {
1543        match key {
1544            Some(k) if !k.is_empty() => format!("arn:aws:s3:::{}/{}", bucket, k),
1545            _ => format!("arn:aws:s3:::{}/*", bucket),
1546        }
1547    } else {
1548        // Bucket-level actions (ListObjectsV2, GetBucketTagging, ...).
1549        format!("arn:aws:s3:::{}", bucket)
1550    }
1551}
1552
1553// Conditional request helpers
1554
1555/// Truncate a DateTime to second-level precision (HTTP dates have no sub-second info).
1556pub(crate) fn truncate_to_seconds(dt: DateTime<Utc>) -> DateTime<Utc> {
1557    dt.with_nanosecond(0).unwrap_or(dt)
1558}
1559
1560pub(crate) fn check_get_conditionals(
1561    req: &AwsRequest,
1562    obj: &S3Object,
1563) -> Result<(), AwsServiceError> {
1564    let obj_etag = format!("\"{}\"", obj.etag);
1565    let obj_time = truncate_to_seconds(obj.last_modified);
1566
1567    // If-Match
1568    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1569        if !etag_matches(if_match, &obj_etag) {
1570            return Err(precondition_failed("If-Match"));
1571        }
1572    }
1573
1574    // If-None-Match
1575    if let Some(if_none_match) = req
1576        .headers
1577        .get("if-none-match")
1578        .and_then(|v| v.to_str().ok())
1579    {
1580        if etag_matches(if_none_match, &obj_etag) {
1581            return Err(not_modified_with_etag(&obj_etag));
1582        }
1583    }
1584
1585    // If-Unmodified-Since
1586    if let Some(since) = req
1587        .headers
1588        .get("if-unmodified-since")
1589        .and_then(|v| v.to_str().ok())
1590    {
1591        if let Some(dt) = parse_http_date(since) {
1592            if obj_time > dt {
1593                return Err(precondition_failed("If-Unmodified-Since"));
1594            }
1595        }
1596    }
1597
1598    // If-Modified-Since
1599    if let Some(since) = req
1600        .headers
1601        .get("if-modified-since")
1602        .and_then(|v| v.to_str().ok())
1603    {
1604        if let Some(dt) = parse_http_date(since) {
1605            if obj_time <= dt {
1606                return Err(not_modified());
1607            }
1608        }
1609    }
1610
1611    Ok(())
1612}
1613
1614pub(crate) fn check_head_conditionals(
1615    req: &AwsRequest,
1616    obj: &S3Object,
1617) -> Result<(), AwsServiceError> {
1618    let obj_etag = format!("\"{}\"", obj.etag);
1619    let obj_time = truncate_to_seconds(obj.last_modified);
1620
1621    // If-Match
1622    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1623        if !etag_matches(if_match, &obj_etag) {
1624            return Err(AwsServiceError::aws_error(
1625                StatusCode::PRECONDITION_FAILED,
1626                "412",
1627                "Precondition Failed",
1628            ));
1629        }
1630    }
1631
1632    // If-None-Match
1633    if let Some(if_none_match) = req
1634        .headers
1635        .get("if-none-match")
1636        .and_then(|v| v.to_str().ok())
1637    {
1638        if etag_matches(if_none_match, &obj_etag) {
1639            return Err(not_modified_with_etag(&obj_etag));
1640        }
1641    }
1642
1643    // If-Unmodified-Since
1644    if let Some(since) = req
1645        .headers
1646        .get("if-unmodified-since")
1647        .and_then(|v| v.to_str().ok())
1648    {
1649        if let Some(dt) = parse_http_date(since) {
1650            if obj_time > dt {
1651                return Err(AwsServiceError::aws_error(
1652                    StatusCode::PRECONDITION_FAILED,
1653                    "412",
1654                    "Precondition Failed",
1655                ));
1656            }
1657        }
1658    }
1659
1660    // If-Modified-Since
1661    if let Some(since) = req
1662        .headers
1663        .get("if-modified-since")
1664        .and_then(|v| v.to_str().ok())
1665    {
1666        if let Some(dt) = parse_http_date(since) {
1667            if obj_time <= dt {
1668                return Err(not_modified());
1669            }
1670        }
1671    }
1672
1673    Ok(())
1674}
1675
1676pub(crate) fn etag_matches(condition: &str, obj_etag: &str) -> bool {
1677    let condition = condition.trim();
1678    if condition == "*" {
1679        return true;
1680    }
1681    let clean_etag = obj_etag.replace('"', "");
1682    // Split on comma to handle multi-value If-Match / If-None-Match
1683    for part in condition.split(',') {
1684        let part = part.trim().replace('"', "");
1685        if part == clean_etag {
1686            return true;
1687        }
1688    }
1689    false
1690}
1691
1692pub(crate) fn parse_http_date(s: &str) -> Option<DateTime<Utc>> {
1693    // Try RFC 2822 format: "Sat, 01 Jan 2000 00:00:00 GMT"
1694    if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
1695        return Some(dt.with_timezone(&Utc));
1696    }
1697    // Try RFC 3339
1698    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1699        return Some(dt.with_timezone(&Utc));
1700    }
1701    // Try common HTTP date format: "%a, %d %b %Y %H:%M:%S GMT"
1702    if let Ok(dt) =
1703        chrono::NaiveDateTime::parse_from_str(s.trim_end_matches(" GMT"), "%a, %d %b %Y %H:%M:%S")
1704    {
1705        return Some(dt.and_utc());
1706    }
1707    // Try ISO 8601
1708    if let Ok(dt) = s.parse::<DateTime<Utc>>() {
1709        return Some(dt);
1710    }
1711    None
1712}
1713
1714pub(crate) fn not_modified() -> AwsServiceError {
1715    AwsServiceError::aws_error(StatusCode::NOT_MODIFIED, "304", "Not Modified")
1716}
1717
1718pub(crate) fn not_modified_with_etag(etag: &str) -> AwsServiceError {
1719    AwsServiceError::aws_error_with_headers(
1720        StatusCode::NOT_MODIFIED,
1721        "304",
1722        "Not Modified",
1723        vec![("etag".to_string(), etag.to_string())],
1724    )
1725}
1726
1727pub(crate) fn precondition_failed(condition: &str) -> AwsServiceError {
1728    AwsServiceError::aws_error_with_fields(
1729        StatusCode::PRECONDITION_FAILED,
1730        "PreconditionFailed",
1731        "At least one of the pre-conditions you specified did not hold",
1732        vec![("Condition".to_string(), condition.to_string())],
1733    )
1734}
1735
1736// ACL helpers
1737
1738pub(crate) fn build_acl_xml(owner_id: &str, grants: &[AclGrant], _account_id: &str) -> String {
1739    let mut grants_xml = String::new();
1740    for g in grants {
1741        let grantee_xml = if g.grantee_type == "Group" {
1742            let uri = g.grantee_uri.as_deref().unwrap_or("");
1743            format!(
1744                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
1745                 <URI>{}</URI></Grantee>",
1746                xml_escape(uri),
1747            )
1748        } else {
1749            let id = g.grantee_id.as_deref().unwrap_or("");
1750            format!(
1751                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
1752                 <ID>{}</ID></Grantee>",
1753                xml_escape(id),
1754            )
1755        };
1756        grants_xml.push_str(&format!(
1757            "<Grant>{grantee_xml}<Permission>{}</Permission></Grant>",
1758            xml_escape(&g.permission),
1759        ));
1760    }
1761
1762    format!(
1763        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1764         <AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1765         <Owner><ID>{owner_id}</ID><DisplayName>{owner_id}</DisplayName></Owner>\
1766         <AccessControlList>{grants_xml}</AccessControlList>\
1767         </AccessControlPolicy>",
1768        owner_id = xml_escape(owner_id),
1769    )
1770}
1771
1772pub(crate) fn canned_acl_grants(acl: &str, owner_id: &str) -> Vec<AclGrant> {
1773    let owner_grant = AclGrant {
1774        grantee_type: "CanonicalUser".to_string(),
1775        grantee_id: Some(owner_id.to_string()),
1776        grantee_display_name: Some(owner_id.to_string()),
1777        grantee_uri: None,
1778        permission: "FULL_CONTROL".to_string(),
1779    };
1780    match acl {
1781        "private" => vec![owner_grant],
1782        "public-read" => vec![
1783            owner_grant,
1784            AclGrant {
1785                grantee_type: "Group".to_string(),
1786                grantee_id: None,
1787                grantee_display_name: None,
1788                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1789                permission: "READ".to_string(),
1790            },
1791        ],
1792        "public-read-write" => vec![
1793            owner_grant,
1794            AclGrant {
1795                grantee_type: "Group".to_string(),
1796                grantee_id: None,
1797                grantee_display_name: None,
1798                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1799                permission: "READ".to_string(),
1800            },
1801            AclGrant {
1802                grantee_type: "Group".to_string(),
1803                grantee_id: None,
1804                grantee_display_name: None,
1805                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1806                permission: "WRITE".to_string(),
1807            },
1808        ],
1809        "authenticated-read" => vec![
1810            owner_grant,
1811            AclGrant {
1812                grantee_type: "Group".to_string(),
1813                grantee_id: None,
1814                grantee_display_name: None,
1815                grantee_uri: Some(
1816                    "http://acs.amazonaws.com/groups/global/AuthenticatedUsers".to_string(),
1817                ),
1818                permission: "READ".to_string(),
1819            },
1820        ],
1821        "bucket-owner-full-control" => vec![owner_grant],
1822        _ => vec![owner_grant],
1823    }
1824}
1825
1826pub(crate) fn canned_acl_grants_for_object(acl: &str, owner_id: &str) -> Vec<AclGrant> {
1827    // For objects, canned ACLs work the same way
1828    canned_acl_grants(acl, owner_id)
1829}
1830
1831pub(crate) fn parse_grant_headers(headers: &HeaderMap) -> Vec<AclGrant> {
1832    let mut grants = Vec::new();
1833    let header_permission_map = [
1834        ("x-amz-grant-read", "READ"),
1835        ("x-amz-grant-write", "WRITE"),
1836        ("x-amz-grant-read-acp", "READ_ACP"),
1837        ("x-amz-grant-write-acp", "WRITE_ACP"),
1838        ("x-amz-grant-full-control", "FULL_CONTROL"),
1839    ];
1840
1841    for (header, permission) in &header_permission_map {
1842        if let Some(value) = headers.get(*header).and_then(|v| v.to_str().ok()) {
1843            // Parse "id=xxx" or "uri=xxx" or "emailAddress=xxx"
1844            for part in value.split(',') {
1845                let part = part.trim();
1846                if let Some((key, val)) = part.split_once('=') {
1847                    let val = val.trim().trim_matches('"');
1848                    let key = key.trim().to_lowercase();
1849                    match key.as_str() {
1850                        "id" => {
1851                            grants.push(AclGrant {
1852                                grantee_type: "CanonicalUser".to_string(),
1853                                grantee_id: Some(val.to_string()),
1854                                grantee_display_name: Some(val.to_string()),
1855                                grantee_uri: None,
1856                                permission: permission.to_string(),
1857                            });
1858                        }
1859                        "uri" | "url" => {
1860                            grants.push(AclGrant {
1861                                grantee_type: "Group".to_string(),
1862                                grantee_id: None,
1863                                grantee_display_name: None,
1864                                grantee_uri: Some(val.to_string()),
1865                                permission: permission.to_string(),
1866                            });
1867                        }
1868                        _ => {}
1869                    }
1870                }
1871            }
1872        }
1873    }
1874    grants
1875}
1876
1877pub(crate) fn parse_acl_xml(xml: &str) -> Result<Vec<AclGrant>, AwsServiceError> {
1878    // Check for Owner presence
1879    if xml.contains("<AccessControlPolicy") && !xml.contains("<Owner>") {
1880        return Err(AwsServiceError::aws_error(
1881            StatusCode::BAD_REQUEST,
1882            "MalformedACLError",
1883            "The XML you provided was not well-formed or did not validate against our published schema",
1884        ));
1885    }
1886
1887    let valid_permissions = ["READ", "WRITE", "READ_ACP", "WRITE_ACP", "FULL_CONTROL"];
1888
1889    let mut grants = Vec::new();
1890    let mut remaining = xml;
1891    while let Some(start) = remaining.find("<Grant>") {
1892        let after = &remaining[start + 7..];
1893        if let Some(end) = after.find("</Grant>") {
1894            let grant_body = &after[..end];
1895
1896            // Extract permission
1897            let permission = extract_xml_value(grant_body, "Permission").unwrap_or_default();
1898            if !valid_permissions.contains(&permission.as_str()) {
1899                return Err(AwsServiceError::aws_error(
1900                    StatusCode::BAD_REQUEST,
1901                    "MalformedACLError",
1902                    "The XML you provided was not well-formed or did not validate against our published schema",
1903                ));
1904            }
1905
1906            // Determine grantee type
1907            if grant_body.contains("xsi:type=\"Group\"") || grant_body.contains("<URI>") {
1908                let uri = extract_xml_value(grant_body, "URI").unwrap_or_default();
1909                grants.push(AclGrant {
1910                    grantee_type: "Group".to_string(),
1911                    grantee_id: None,
1912                    grantee_display_name: None,
1913                    grantee_uri: Some(uri),
1914                    permission,
1915                });
1916            } else {
1917                let id = extract_xml_value(grant_body, "ID").unwrap_or_default();
1918                let display =
1919                    extract_xml_value(grant_body, "DisplayName").unwrap_or_else(|| id.clone());
1920                grants.push(AclGrant {
1921                    grantee_type: "CanonicalUser".to_string(),
1922                    grantee_id: Some(id),
1923                    grantee_display_name: Some(display),
1924                    grantee_uri: None,
1925                    permission,
1926                });
1927            }
1928
1929            remaining = &after[end + 8..];
1930        } else {
1931            break;
1932        }
1933    }
1934    Ok(grants)
1935}
1936
1937// Range helpers
1938
1939pub(crate) enum RangeResult {
1940    Satisfiable { start: usize, end: usize },
1941    NotSatisfiable,
1942    Ignored,
1943}
1944
1945pub(crate) fn parse_range_header(range_str: &str, total_size: usize) -> Option<RangeResult> {
1946    let range_str = range_str.strip_prefix("bytes=")?;
1947    let (start_str, end_str) = range_str.split_once('-')?;
1948    if start_str.is_empty() {
1949        let suffix_len: usize = end_str.parse().ok()?;
1950        if suffix_len == 0 || total_size == 0 {
1951            return Some(RangeResult::NotSatisfiable);
1952        }
1953        let start = total_size.saturating_sub(suffix_len);
1954        Some(RangeResult::Satisfiable {
1955            start,
1956            end: total_size - 1,
1957        })
1958    } else {
1959        let start: usize = start_str.parse().ok()?;
1960        if start >= total_size {
1961            return Some(RangeResult::NotSatisfiable);
1962        }
1963        let end = if end_str.is_empty() {
1964            total_size - 1
1965        } else {
1966            let e: usize = end_str.parse().ok()?;
1967            if e < start {
1968                return Some(RangeResult::Ignored);
1969            }
1970            std::cmp::min(e, total_size - 1)
1971        };
1972        Some(RangeResult::Satisfiable { start, end })
1973    }
1974}
1975
1976// Helpers
1977
1978/// S3 XML response with `application/xml` content type (unlike Query protocol's `text/xml`).
1979pub(crate) fn s3_xml(status: StatusCode, body: impl Into<Bytes>) -> AwsResponse {
1980    AwsResponse {
1981        status,
1982        content_type: "application/xml".to_string(),
1983        body: body.into().into(),
1984        headers: HeaderMap::new(),
1985    }
1986}
1987
1988pub(crate) fn empty_response(status: StatusCode) -> AwsResponse {
1989    AwsResponse {
1990        status,
1991        content_type: "application/xml".to_string(),
1992        body: Bytes::new().into(),
1993        headers: HeaderMap::new(),
1994    }
1995}
1996
1997/// Returns true when the object is stored in a "cold" storage class (GLACIER, DEEP_ARCHIVE)
1998/// and has NOT been restored (or restore is still in progress).
1999pub(crate) fn is_frozen(obj: &S3Object) -> bool {
2000    matches!(obj.storage_class.as_str(), "GLACIER" | "DEEP_ARCHIVE")
2001        && obj.restore_ongoing != Some(false)
2002}
2003
2004pub(crate) fn no_such_bucket(bucket: &str) -> AwsServiceError {
2005    AwsServiceError::aws_error_with_fields(
2006        StatusCode::NOT_FOUND,
2007        "NoSuchBucket",
2008        "The specified bucket does not exist",
2009        vec![("BucketName".to_string(), bucket.to_string())],
2010    )
2011}
2012
2013pub(crate) fn no_such_key(key: &str) -> AwsServiceError {
2014    AwsServiceError::aws_error_with_fields(
2015        StatusCode::NOT_FOUND,
2016        "NoSuchKey",
2017        "The specified key does not exist.",
2018        vec![("Key".to_string(), key.to_string())],
2019    )
2020}
2021
2022pub(crate) fn no_such_upload(upload_id: &str) -> AwsServiceError {
2023    AwsServiceError::aws_error_with_fields(
2024        StatusCode::NOT_FOUND,
2025        "NoSuchUpload",
2026        "The specified upload does not exist. The upload ID may be invalid, \
2027         or the upload may have been aborted or completed.",
2028        vec![("UploadId".to_string(), upload_id.to_string())],
2029    )
2030}
2031
2032pub(crate) fn no_such_key_with_detail(key: &str) -> AwsServiceError {
2033    AwsServiceError::aws_error_with_fields(
2034        StatusCode::NOT_FOUND,
2035        "NoSuchKey",
2036        "The specified key does not exist.",
2037        vec![("Key".to_string(), key.to_string())],
2038    )
2039}
2040
2041pub(crate) fn compute_md5(data: &[u8]) -> String {
2042    let digest = Md5::digest(data);
2043    format!("{:x}", digest)
2044}
2045
2046pub(crate) fn compute_checksum(algorithm: &str, data: &[u8]) -> String {
2047    match algorithm {
2048        "CRC32" => {
2049            let crc = crc32fast::hash(data);
2050            BASE64.encode(crc.to_be_bytes())
2051        }
2052        "SHA1" => {
2053            use sha1::Digest as _;
2054            let hash = sha1::Sha1::digest(data);
2055            BASE64.encode(hash)
2056        }
2057        "SHA256" => {
2058            use sha2::Digest as _;
2059            let hash = sha2::Sha256::digest(data);
2060            BASE64.encode(hash)
2061        }
2062        _ => String::new(),
2063    }
2064}
2065
2066/// Streaming variant of [`compute_checksum`] for spool files. Reads
2067/// the file in 1 MiB chunks and feeds each chunk into the hasher so a
2068/// 1 GiB upload computes its CRC32 / SHA-1 / SHA-256 in constant
2069/// memory rather than via `tokio::fs::read` (which would allocate the
2070/// whole file as one buffer).
2071pub(crate) async fn compute_checksum_streaming(
2072    algorithm: &str,
2073    path: &std::path::Path,
2074) -> Result<String, std::io::Error> {
2075    use tokio::io::AsyncReadExt;
2076    let mut file = tokio::fs::File::open(path).await?;
2077    let mut buf = vec![0u8; 1024 * 1024];
2078    match algorithm {
2079        "CRC32" => {
2080            let mut hasher = crc32fast::Hasher::new();
2081            loop {
2082                let n = file.read(&mut buf).await?;
2083                if n == 0 {
2084                    break;
2085                }
2086                hasher.update(&buf[..n]);
2087            }
2088            Ok(BASE64.encode(hasher.finalize().to_be_bytes()))
2089        }
2090        "SHA1" => {
2091            use sha1::Digest as _;
2092            let mut hasher = sha1::Sha1::new();
2093            loop {
2094                let n = file.read(&mut buf).await?;
2095                if n == 0 {
2096                    break;
2097                }
2098                hasher.update(&buf[..n]);
2099            }
2100            Ok(BASE64.encode(hasher.finalize()))
2101        }
2102        "SHA256" => {
2103            use sha2::Digest as _;
2104            let mut hasher = sha2::Sha256::new();
2105            loop {
2106                let n = file.read(&mut buf).await?;
2107                if n == 0 {
2108                    break;
2109                }
2110                hasher.update(&buf[..n]);
2111            }
2112            Ok(BASE64.encode(hasher.finalize()))
2113        }
2114        _ => Ok(String::new()),
2115    }
2116}
2117
2118pub(crate) fn url_encode_s3_key(s: &str) -> String {
2119    let mut out = String::with_capacity(s.len() * 2);
2120    for byte in s.bytes() {
2121        match byte {
2122            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
2123                out.push(byte as char);
2124            }
2125            _ => {
2126                out.push_str(&format!("%{:02X}", byte));
2127            }
2128        }
2129    }
2130    out
2131}
2132
2133pub(crate) use fakecloud_aws::xml::xml_escape;
2134
2135pub(crate) fn extract_user_metadata(
2136    headers: &HeaderMap,
2137) -> std::collections::HashMap<String, String> {
2138    let mut meta = std::collections::HashMap::new();
2139    for (name, value) in headers {
2140        if let Some(key) = name.as_str().strip_prefix("x-amz-meta-") {
2141            if let Ok(v) = value.to_str() {
2142                meta.insert(key.to_string(), v.to_string());
2143            }
2144        }
2145    }
2146    meta
2147}
2148
2149pub(crate) fn is_valid_storage_class(class: &str) -> bool {
2150    matches!(
2151        class,
2152        "STANDARD"
2153            | "REDUCED_REDUNDANCY"
2154            | "STANDARD_IA"
2155            | "ONEZONE_IA"
2156            | "INTELLIGENT_TIERING"
2157            | "GLACIER"
2158            | "DEEP_ARCHIVE"
2159            | "GLACIER_IR"
2160            | "OUTPOSTS"
2161            | "SNOW"
2162            | "EXPRESS_ONEZONE"
2163    )
2164}
2165
2166pub(crate) fn is_valid_bucket_name(name: &str) -> bool {
2167    if name.len() < 3 || name.len() > 63 {
2168        return false;
2169    }
2170    // Must start and end with alphanumeric
2171    let bytes = name.as_bytes();
2172    if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
2173        return false;
2174    }
2175    // Only lowercase letters, digits, hyphens, dots (also allow underscores for compatibility)
2176    name.chars()
2177        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '_')
2178}
2179
2180pub(crate) fn is_valid_region(region: &str) -> bool {
2181    // Basic validation: region should match pattern like us-east-1, eu-west-2, etc.
2182    let valid_regions = [
2183        "us-east-1",
2184        "us-east-2",
2185        "us-west-1",
2186        "us-west-2",
2187        "af-south-1",
2188        "ap-east-1",
2189        "ap-south-1",
2190        "ap-south-2",
2191        "ap-southeast-1",
2192        "ap-southeast-2",
2193        "ap-southeast-3",
2194        "ap-southeast-4",
2195        "ap-northeast-1",
2196        "ap-northeast-2",
2197        "ap-northeast-3",
2198        "ca-central-1",
2199        "ca-west-1",
2200        "eu-central-1",
2201        "eu-central-2",
2202        "eu-west-1",
2203        "eu-west-2",
2204        "eu-west-3",
2205        "eu-south-1",
2206        "eu-south-2",
2207        "eu-north-1",
2208        "il-central-1",
2209        "me-south-1",
2210        "me-central-1",
2211        "sa-east-1",
2212        "cn-north-1",
2213        "cn-northwest-1",
2214        "us-gov-east-1",
2215        "us-gov-east-2",
2216        "us-gov-west-1",
2217        "us-iso-east-1",
2218        "us-iso-west-1",
2219        "us-isob-east-1",
2220        "us-isof-south-1",
2221    ];
2222    valid_regions.contains(&region)
2223}
2224
2225pub(crate) fn resolve_object<'a>(
2226    b: &'a S3Bucket,
2227    key: &str,
2228    version_id: Option<&String>,
2229) -> Result<&'a S3Object, AwsServiceError> {
2230    if let Some(vid) = version_id {
2231        // "null" version ID refers to an object with no version_id (pre-versioning)
2232        if vid == "null" {
2233            // Check versions for a pre-versioning object (version_id == None or Some("null"))
2234            if let Some(versions) = b.object_versions.get(key) {
2235                if let Some(obj) = versions
2236                    .iter()
2237                    .find(|o| o.version_id.is_none() || o.version_id.as_deref() == Some("null"))
2238                {
2239                    return Ok(obj);
2240                }
2241            }
2242            // Also check current object if it has no version_id
2243            if let Some(obj) = b.objects.get(key) {
2244                if obj.version_id.is_none() || obj.version_id.as_deref() == Some("null") {
2245                    return Ok(obj);
2246                }
2247            }
2248        } else {
2249            // When a specific versionId is requested, check versions first
2250            if let Some(versions) = b.object_versions.get(key) {
2251                if let Some(obj) = versions
2252                    .iter()
2253                    .find(|o| o.version_id.as_deref() == Some(vid.as_str()))
2254                {
2255                    return Ok(obj);
2256                }
2257            }
2258            // Also check current object
2259            if let Some(obj) = b.objects.get(key) {
2260                if obj.version_id.as_deref() == Some(vid.as_str()) {
2261                    return Ok(obj);
2262                }
2263            }
2264        }
2265        // For versioned buckets, return NoSuchVersion; for non-versioned, return 400
2266        if b.versioning.is_some() {
2267            Err(AwsServiceError::aws_error_with_fields(
2268                StatusCode::NOT_FOUND,
2269                "NoSuchVersion",
2270                "The specified version does not exist.",
2271                vec![
2272                    ("Key".to_string(), key.to_string()),
2273                    ("VersionId".to_string(), vid.to_string()),
2274                ],
2275            ))
2276        } else {
2277            Err(AwsServiceError::aws_error(
2278                StatusCode::BAD_REQUEST,
2279                "InvalidArgument",
2280                "Invalid version id specified",
2281            ))
2282        }
2283    } else {
2284        b.objects.get(key).ok_or_else(|| no_such_key(key))
2285    }
2286}
2287
2288pub(crate) fn make_delete_marker(key: &str, dm_id: &str) -> S3Object {
2289    S3Object {
2290        key: key.to_string(),
2291        last_modified: Utc::now(),
2292        storage_class: "STANDARD".to_string(),
2293        version_id: Some(dm_id.to_string()),
2294        is_delete_marker: true,
2295        ..Default::default()
2296    }
2297}
2298
2299/// Represents an object to delete in a batch delete request.
2300pub(crate) struct DeleteObjectEntry {
2301    key: String,
2302    version_id: Option<String>,
2303}
2304
2305pub(crate) fn parse_delete_objects_xml(xml: &str) -> Vec<DeleteObjectEntry> {
2306    let mut entries = Vec::new();
2307    let mut remaining = xml;
2308    while let Some(obj_start) = remaining.find("<Object>") {
2309        let after = &remaining[obj_start + 8..];
2310        if let Some(obj_end) = after.find("</Object>") {
2311            let obj_body = &after[..obj_end];
2312            let key = extract_xml_value(obj_body, "Key");
2313            let version_id = extract_xml_value(obj_body, "VersionId");
2314            if let Some(k) = key {
2315                entries.push(DeleteObjectEntry { key: k, version_id });
2316            }
2317            remaining = &after[obj_end + 9..];
2318        } else {
2319            break;
2320        }
2321    }
2322    entries
2323}
2324
2325/// Minimal XML parser for `<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag>...`.
2326/// Returns a Vec to preserve insertion order and detect duplicates.
2327pub(crate) fn parse_tagging_xml(xml: &str) -> Vec<(String, String)> {
2328    let mut tags = Vec::new();
2329    let mut remaining = xml;
2330    while let Some(tag_start) = remaining.find("<Tag>") {
2331        let after = &remaining[tag_start + 5..];
2332        if let Some(tag_end) = after.find("</Tag>") {
2333            let tag_body = &after[..tag_end];
2334            let key = extract_xml_value(tag_body, "Key");
2335            let value = extract_xml_value(tag_body, "Value");
2336            if let (Some(k), Some(v)) = (key, value) {
2337                tags.push((k, v));
2338            }
2339            remaining = &after[tag_end + 6..];
2340        } else {
2341            break;
2342        }
2343    }
2344    tags
2345}
2346
2347pub(crate) fn validate_tags(tags: &[(String, String)]) -> Result<(), AwsServiceError> {
2348    // Check for duplicate keys
2349    let mut seen = std::collections::HashSet::new();
2350    for (k, _) in tags {
2351        if !seen.insert(k.as_str()) {
2352            return Err(AwsServiceError::aws_error(
2353                StatusCode::BAD_REQUEST,
2354                "InvalidTag",
2355                "Cannot provide multiple Tags with the same key",
2356            ));
2357        }
2358        // Check for aws: prefix
2359        if k.starts_with("aws:") {
2360            return Err(AwsServiceError::aws_error(
2361                StatusCode::BAD_REQUEST,
2362                "InvalidTag",
2363                "System tags cannot be added/updated by requester",
2364            ));
2365        }
2366    }
2367    Ok(())
2368}
2369
2370pub(crate) fn extract_xml_value(xml: &str, tag: &str) -> Option<String> {
2371    // Handle self-closing tags like <Value /> or <Value/>
2372    let self_closing1 = format!("<{tag} />");
2373    let self_closing2 = format!("<{tag}/>");
2374    if xml.contains(&self_closing1) || xml.contains(&self_closing2) {
2375        // Check if the self-closing tag appears before any open+close pair
2376        let self_pos = xml
2377            .find(&self_closing1)
2378            .or_else(|| xml.find(&self_closing2));
2379        let open = format!("<{tag}>");
2380        let open_pos = xml.find(&open);
2381        match (self_pos, open_pos) {
2382            (Some(sp), Some(op)) if sp < op => return Some(String::new()),
2383            (Some(_), None) => return Some(String::new()),
2384            _ => {}
2385        }
2386    }
2387
2388    let open = format!("<{tag}>");
2389    let close = format!("</{tag}>");
2390    let start = xml.find(&open)? + open.len();
2391    let end = xml.find(&close)?;
2392    Some(xml[start..end].to_string())
2393}
2394
2395/// Parse the CompleteMultipartUpload XML body into (part_number, etag) pairs.
2396pub(crate) fn parse_complete_multipart_xml(xml: &str) -> Vec<(u32, String)> {
2397    let mut parts = Vec::new();
2398    let mut remaining = xml;
2399    while let Some(part_start) = remaining.find("<Part>") {
2400        let after = &remaining[part_start + 6..];
2401        if let Some(part_end) = after.find("</Part>") {
2402            let part_body = &after[..part_end];
2403            let part_num =
2404                extract_xml_value(part_body, "PartNumber").and_then(|s| s.parse::<u32>().ok());
2405            let etag = extract_xml_value(part_body, "ETag")
2406                .map(|s| s.replace("&quot;", "").replace('"', ""));
2407            if let (Some(num), Some(e)) = (part_num, etag) {
2408                parts.push((num, e));
2409            }
2410            remaining = &after[part_end + 7..];
2411        } else {
2412            break;
2413        }
2414    }
2415    parts
2416}
2417
2418pub(crate) fn parse_url_encoded_tags(s: &str) -> Vec<(String, String)> {
2419    let mut tags = Vec::new();
2420    for pair in s.split('&') {
2421        if pair.is_empty() {
2422            continue;
2423        }
2424        let (key, value) = match pair.find('=') {
2425            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
2426            None => (pair, ""),
2427        };
2428        tags.push((
2429            percent_encoding::percent_decode_str(key)
2430                .decode_utf8_lossy()
2431                .to_string(),
2432            percent_encoding::percent_decode_str(value)
2433                .decode_utf8_lossy()
2434                .to_string(),
2435        ));
2436    }
2437    tags
2438}
2439
2440/// Validate lifecycle configuration XML. Returns MalformedXML on invalid configs.
2441pub(crate) fn validate_lifecycle_xml(xml: &str) -> Result<(), AwsServiceError> {
2442    let malformed = || {
2443        AwsServiceError::aws_error(
2444            StatusCode::BAD_REQUEST,
2445            "MalformedXML",
2446            "The XML you provided was not well-formed or did not validate against our published schema",
2447        )
2448    };
2449
2450    let mut remaining = xml;
2451    while let Some(rule_start) = remaining.find("<Rule>") {
2452        let after = &remaining[rule_start + 6..];
2453        if let Some(rule_end) = after.find("</Rule>") {
2454            let rule_body = &after[..rule_end];
2455
2456            // Must have Filter or Prefix
2457            let has_filter = rule_body.contains("<Filter>")
2458                || rule_body.contains("<Filter/>")
2459                || rule_body.contains("<Filter />");
2460
2461            // Check for <Prefix> at rule level (outside of <Filter>...</Filter>)
2462            let has_prefix_outside_filter = {
2463                if !rule_body.contains("<Prefix") {
2464                    false
2465                } else if !has_filter {
2466                    true // No filter means any Prefix is at rule level
2467                } else {
2468                    // Remove the Filter block and check if Prefix remains
2469                    let mut stripped = rule_body.to_string();
2470                    // Remove <Filter>...</Filter> or self-closing variants
2471                    if let Some(fs) = stripped.find("<Filter") {
2472                        if let Some(fe) = stripped.find("</Filter>") {
2473                            stripped = format!("{}{}", &stripped[..fs], &stripped[fe + 9..]);
2474                        }
2475                    }
2476                    stripped.contains("<Prefix")
2477                }
2478            };
2479
2480            if !has_filter && !has_prefix_outside_filter {
2481                return Err(malformed());
2482            }
2483            // Can't have both Filter and rule-level Prefix
2484            if has_filter && has_prefix_outside_filter {
2485                return Err(malformed());
2486            }
2487
2488            // Expiration: if has ExpiredObjectDeleteMarker, cannot also have Days or Date
2489            // (only check within <Expiration> block)
2490            if let Some(exp_start) = rule_body.find("<Expiration>") {
2491                if let Some(exp_end) = rule_body[exp_start..].find("</Expiration>") {
2492                    let exp_body = &rule_body[exp_start..exp_start + exp_end];
2493                    if exp_body.contains("<ExpiredObjectDeleteMarker>")
2494                        && (exp_body.contains("<Days>") || exp_body.contains("<Date>"))
2495                    {
2496                        return Err(malformed());
2497                    }
2498                }
2499            }
2500
2501            // Filter validation
2502            if has_filter {
2503                if let Some(fs) = rule_body.find("<Filter>") {
2504                    if let Some(fe) = rule_body.find("</Filter>") {
2505                        let filter_body = &rule_body[fs + 8..fe];
2506                        let has_prefix_in_filter = filter_body.contains("<Prefix");
2507                        let has_tag_in_filter = filter_body.contains("<Tag>");
2508                        let has_and_in_filter = filter_body.contains("<And>");
2509                        // Can't have both Prefix and Tag without And
2510                        if has_prefix_in_filter && has_tag_in_filter && !has_and_in_filter {
2511                            return Err(malformed());
2512                        }
2513                        // Can't have Tag and And simultaneously at the Filter level
2514                        if has_tag_in_filter && has_and_in_filter {
2515                            // Check if the <Tag> is outside <And>
2516                            let and_start = filter_body.find("<And>").unwrap_or(0);
2517                            let tag_pos = filter_body.find("<Tag>").unwrap_or(0);
2518                            if tag_pos < and_start {
2519                                return Err(malformed());
2520                            }
2521                        }
2522                    }
2523                }
2524            }
2525
2526            // NoncurrentVersionTransition must have NoncurrentDays and StorageClass
2527            if rule_body.contains("<NoncurrentVersionTransition>") {
2528                let mut nvt_remaining = rule_body;
2529                while let Some(nvt_start) = nvt_remaining.find("<NoncurrentVersionTransition>") {
2530                    let nvt_after = &nvt_remaining[nvt_start + 29..];
2531                    if let Some(nvt_end) = nvt_after.find("</NoncurrentVersionTransition>") {
2532                        let nvt_body = &nvt_after[..nvt_end];
2533                        if !nvt_body.contains("<NoncurrentDays>") {
2534                            return Err(malformed());
2535                        }
2536                        if !nvt_body.contains("<StorageClass>") {
2537                            return Err(malformed());
2538                        }
2539                        nvt_remaining = &nvt_after[nvt_end + 30..];
2540                    } else {
2541                        break;
2542                    }
2543                }
2544            }
2545
2546            remaining = &after[rule_end + 7..];
2547        } else {
2548            break;
2549        }
2550    }
2551
2552    Ok(())
2553}
2554
2555/// Parsed CORS rule from bucket configuration XML.
2556pub(crate) struct CorsRule {
2557    allowed_origins: Vec<String>,
2558    allowed_methods: Vec<String>,
2559    allowed_headers: Vec<String>,
2560    expose_headers: Vec<String>,
2561    max_age_seconds: Option<u32>,
2562}
2563
2564/// Parse CORS configuration XML into rules.
2565pub(crate) fn parse_cors_config(xml: &str) -> Vec<CorsRule> {
2566    let mut rules = Vec::new();
2567    let mut remaining = xml;
2568    while let Some(start) = remaining.find("<CORSRule>") {
2569        let after = &remaining[start + 10..];
2570        if let Some(end) = after.find("</CORSRule>") {
2571            let block = &after[..end];
2572            let allowed_origins = extract_all_xml_values(block, "AllowedOrigin");
2573            let allowed_methods = extract_all_xml_values(block, "AllowedMethod");
2574            let allowed_headers = extract_all_xml_values(block, "AllowedHeader");
2575            let expose_headers = extract_all_xml_values(block, "ExposeHeader");
2576            let max_age_seconds =
2577                extract_xml_value(block, "MaxAgeSeconds").and_then(|s| s.parse().ok());
2578            rules.push(CorsRule {
2579                allowed_origins,
2580                allowed_methods,
2581                allowed_headers,
2582                expose_headers,
2583                max_age_seconds,
2584            });
2585            remaining = &after[end + 11..];
2586        } else {
2587            break;
2588        }
2589    }
2590    rules
2591}
2592
2593/// Match an origin against a CORS allowed origin pattern (supports "*" wildcard).
2594pub(crate) fn origin_matches(origin: &str, pattern: &str) -> bool {
2595    if pattern == "*" {
2596        return true;
2597    }
2598    // Simple wildcard: *.example.com
2599    if let Some(suffix) = pattern.strip_prefix('*') {
2600        return origin.ends_with(suffix);
2601    }
2602    origin == pattern
2603}
2604
2605/// Find the matching CORS rule for a given origin and method.
2606pub(crate) fn find_cors_rule<'a>(
2607    rules: &'a [CorsRule],
2608    origin: &str,
2609    method: Option<&str>,
2610) -> Option<&'a CorsRule> {
2611    rules.iter().find(|rule| {
2612        let origin_ok = rule
2613            .allowed_origins
2614            .iter()
2615            .any(|o| origin_matches(origin, o));
2616        let method_ok = match method {
2617            Some(m) => rule.allowed_methods.iter().any(|am| am == m),
2618            None => true,
2619        };
2620        origin_ok && method_ok
2621    })
2622}
2623
2624/// Check if an object is locked (retention or legal hold) and should block mutation.
2625/// Returns an error string if locked, None if allowed.
2626pub(crate) fn check_object_lock_for_overwrite(
2627    obj: &S3Object,
2628    req: &AwsRequest,
2629) -> Option<&'static str> {
2630    // Legal hold blocks overwrite
2631    if obj.lock_legal_hold.as_deref() == Some("ON") {
2632        return Some("AccessDenied");
2633    }
2634    // Retention check
2635    if let (Some(mode), Some(until)) = (&obj.lock_mode, &obj.lock_retain_until) {
2636        if *until > Utc::now() {
2637            if mode == "COMPLIANCE" {
2638                return Some("AccessDenied");
2639            }
2640            if mode == "GOVERNANCE" {
2641                let bypass = req
2642                    .headers
2643                    .get("x-amz-bypass-governance-retention")
2644                    .and_then(|v| v.to_str().ok())
2645                    .map(|s| s.eq_ignore_ascii_case("true"))
2646                    .unwrap_or(false);
2647                if !bypass {
2648                    return Some("AccessDenied");
2649                }
2650            }
2651        }
2652    }
2653    None
2654}
2655
2656#[cfg(test)]
2657mod tests {
2658    use super::*;
2659
2660    #[test]
2661    fn s3_condition_keys_emits_list_params() {
2662        let mut q = std::collections::HashMap::new();
2663        q.insert("prefix".to_string(), "logs/".to_string());
2664        q.insert("delimiter".to_string(), "/".to_string());
2665        q.insert("max-keys".to_string(), "100".to_string());
2666        let keys = s3_condition_keys("ListObjectsV2", &q);
2667        assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
2668        assert_eq!(keys.get("s3:delimiter"), Some(&vec!["/".to_string()]));
2669        assert_eq!(keys.get("s3:max-keys"), Some(&vec!["100".to_string()]));
2670    }
2671
2672    #[test]
2673    fn s3_condition_keys_omits_absent_params() {
2674        let q = std::collections::HashMap::new();
2675        let keys = s3_condition_keys("ListObjectsV2", &q);
2676        assert!(keys.is_empty());
2677    }
2678
2679    #[test]
2680    fn s3_condition_keys_partial_params() {
2681        let mut q = std::collections::HashMap::new();
2682        q.insert("prefix".to_string(), "archive/".to_string());
2683        let keys = s3_condition_keys("ListObjects", &q);
2684        assert_eq!(keys.len(), 1);
2685        assert_eq!(keys.get("s3:prefix"), Some(&vec!["archive/".to_string()]));
2686    }
2687
2688    #[test]
2689    fn s3_condition_keys_empty_for_non_list_actions() {
2690        let mut q = std::collections::HashMap::new();
2691        q.insert("prefix".to_string(), "logs/".to_string());
2692        assert!(s3_condition_keys("GetObject", &q).is_empty());
2693        assert!(s3_condition_keys("PutObject", &q).is_empty());
2694        assert!(s3_condition_keys("ListBuckets", &q).is_empty());
2695    }
2696
2697    #[test]
2698    fn valid_bucket_names() {
2699        assert!(is_valid_bucket_name("my-bucket"));
2700        assert!(is_valid_bucket_name("my.bucket.name"));
2701        assert!(is_valid_bucket_name("abc"));
2702        assert!(!is_valid_bucket_name("ab"));
2703        assert!(!is_valid_bucket_name("-bucket"));
2704        assert!(!is_valid_bucket_name("Bucket"));
2705        assert!(!is_valid_bucket_name("bucket-"));
2706    }
2707
2708    #[test]
2709    fn parse_delete_xml() {
2710        let xml = r#"<Delete><Object><Key>a.txt</Key></Object><Object><Key>b/c.txt</Key></Object></Delete>"#;
2711        let entries = parse_delete_objects_xml(xml);
2712        assert_eq!(entries.len(), 2);
2713        assert_eq!(entries[0].key, "a.txt");
2714        assert!(entries[0].version_id.is_none());
2715        assert_eq!(entries[1].key, "b/c.txt");
2716    }
2717
2718    #[test]
2719    fn parse_delete_xml_with_version() {
2720        let xml = r#"<Delete><Object><Key>a.txt</Key><VersionId>v1</VersionId></Object></Delete>"#;
2721        let entries = parse_delete_objects_xml(xml);
2722        assert_eq!(entries.len(), 1);
2723        assert_eq!(entries[0].key, "a.txt");
2724        assert_eq!(entries[0].version_id.as_deref(), Some("v1"));
2725    }
2726
2727    #[test]
2728    fn parse_tags_xml() {
2729        let xml =
2730            r#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#;
2731        let tags = parse_tagging_xml(xml);
2732        assert_eq!(tags, vec![("env".to_string(), "prod".to_string())]);
2733    }
2734
2735    #[test]
2736    fn md5_hash() {
2737        let hash = compute_md5(b"hello");
2738        assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
2739    }
2740
2741    #[test]
2742    fn test_etag_matches() {
2743        assert!(etag_matches("\"abc\"", "\"abc\""));
2744        assert!(etag_matches("abc", "\"abc\""));
2745        assert!(etag_matches("*", "\"abc\""));
2746        assert!(!etag_matches("\"xyz\"", "\"abc\""));
2747    }
2748
2749    #[test]
2750    fn test_event_matches() {
2751        assert!(event_matches("s3:ObjectCreated:Put", "s3:ObjectCreated:*"));
2752        assert!(event_matches("s3:ObjectCreated:Copy", "s3:ObjectCreated:*"));
2753        assert!(event_matches(
2754            "s3:ObjectRemoved:Delete",
2755            "s3:ObjectRemoved:*"
2756        ));
2757        assert!(!event_matches(
2758            "s3:ObjectRemoved:Delete",
2759            "s3:ObjectCreated:*"
2760        ));
2761        assert!(event_matches(
2762            "s3:ObjectCreated:Put",
2763            "s3:ObjectCreated:Put"
2764        ));
2765        assert!(event_matches("s3:ObjectCreated:Put", "s3:*"));
2766    }
2767
2768    #[test]
2769    fn test_parse_notification_config() {
2770        let xml = r#"<NotificationConfiguration>
2771            <QueueConfiguration>
2772                <Queue>arn:aws:sqs:us-east-1:123456789012:my-queue</Queue>
2773                <Event>s3:ObjectCreated:*</Event>
2774            </QueueConfiguration>
2775            <TopicConfiguration>
2776                <Topic>arn:aws:sns:us-east-1:123456789012:my-topic</Topic>
2777                <Event>s3:ObjectRemoved:*</Event>
2778            </TopicConfiguration>
2779        </NotificationConfiguration>"#;
2780        let targets = parse_notification_config(xml);
2781        assert_eq!(targets.len(), 2);
2782        assert_eq!(
2783            targets[0].arn,
2784            "arn:aws:sqs:us-east-1:123456789012:my-queue"
2785        );
2786        assert_eq!(targets[0].events, vec!["s3:ObjectCreated:*"]);
2787        assert_eq!(
2788            targets[1].arn,
2789            "arn:aws:sns:us-east-1:123456789012:my-topic"
2790        );
2791        assert_eq!(targets[1].events, vec!["s3:ObjectRemoved:*"]);
2792    }
2793
2794    #[test]
2795    fn test_parse_notification_config_lambda() {
2796        // Test CloudFunctionConfiguration (older format)
2797        let xml = r#"<NotificationConfiguration>
2798            <CloudFunctionConfiguration>
2799                <CloudFunction>arn:aws:lambda:us-east-1:123456789012:function:my-func</CloudFunction>
2800                <Event>s3:ObjectCreated:*</Event>
2801            </CloudFunctionConfiguration>
2802        </NotificationConfiguration>"#;
2803        let targets = parse_notification_config(xml);
2804        assert_eq!(targets.len(), 1);
2805        assert!(matches!(
2806            targets[0].target_type,
2807            NotificationTargetType::Lambda
2808        ));
2809        assert_eq!(
2810            targets[0].arn,
2811            "arn:aws:lambda:us-east-1:123456789012:function:my-func"
2812        );
2813        assert_eq!(targets[0].events, vec!["s3:ObjectCreated:*"]);
2814    }
2815
2816    #[test]
2817    fn test_parse_notification_config_lambda_new_format() {
2818        // Test LambdaFunctionConfiguration (newer format used by AWS SDK)
2819        let xml = r#"<NotificationConfiguration>
2820            <LambdaFunctionConfiguration>
2821                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
2822                <Event>s3:ObjectCreated:Put</Event>
2823                <Event>s3:ObjectRemoved:*</Event>
2824            </LambdaFunctionConfiguration>
2825        </NotificationConfiguration>"#;
2826        let targets = parse_notification_config(xml);
2827        assert_eq!(targets.len(), 1);
2828        assert!(matches!(
2829            targets[0].target_type,
2830            NotificationTargetType::Lambda
2831        ));
2832        assert_eq!(
2833            targets[0].arn,
2834            "arn:aws:lambda:us-east-1:123456789012:function:my-func"
2835        );
2836        assert_eq!(
2837            targets[0].events,
2838            vec!["s3:ObjectCreated:Put", "s3:ObjectRemoved:*"]
2839        );
2840    }
2841
2842    #[test]
2843    fn test_parse_notification_config_all_types() {
2844        let xml = r#"<NotificationConfiguration>
2845            <QueueConfiguration>
2846                <Queue>arn:aws:sqs:us-east-1:123456789012:q</Queue>
2847                <Event>s3:ObjectCreated:*</Event>
2848            </QueueConfiguration>
2849            <TopicConfiguration>
2850                <Topic>arn:aws:sns:us-east-1:123456789012:t</Topic>
2851                <Event>s3:ObjectRemoved:*</Event>
2852            </TopicConfiguration>
2853            <LambdaFunctionConfiguration>
2854                <Function>arn:aws:lambda:us-east-1:123456789012:function:f</Function>
2855                <Event>s3:ObjectCreated:Put</Event>
2856            </LambdaFunctionConfiguration>
2857        </NotificationConfiguration>"#;
2858        let targets = parse_notification_config(xml);
2859        assert_eq!(targets.len(), 3);
2860        assert!(matches!(
2861            targets[0].target_type,
2862            NotificationTargetType::Sqs
2863        ));
2864        assert!(matches!(
2865            targets[1].target_type,
2866            NotificationTargetType::Sns
2867        ));
2868        assert!(matches!(
2869            targets[2].target_type,
2870            NotificationTargetType::Lambda
2871        ));
2872    }
2873
2874    #[test]
2875    fn test_parse_notification_config_with_filters() {
2876        let xml = r#"<NotificationConfiguration>
2877            <LambdaFunctionConfiguration>
2878                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
2879                <Event>s3:ObjectCreated:*</Event>
2880                <Filter>
2881                    <S3Key>
2882                        <FilterRule>
2883                            <Name>prefix</Name>
2884                            <Value>images/</Value>
2885                        </FilterRule>
2886                        <FilterRule>
2887                            <Name>suffix</Name>
2888                            <Value>.jpg</Value>
2889                        </FilterRule>
2890                    </S3Key>
2891                </Filter>
2892            </LambdaFunctionConfiguration>
2893        </NotificationConfiguration>"#;
2894        let targets = parse_notification_config(xml);
2895        assert_eq!(targets.len(), 1);
2896        assert_eq!(targets[0].prefix_filter, Some("images/".to_string()));
2897        assert_eq!(targets[0].suffix_filter, Some(".jpg".to_string()));
2898    }
2899
2900    #[test]
2901    fn test_parse_notification_config_no_filters() {
2902        let xml = r#"<NotificationConfiguration>
2903            <LambdaFunctionConfiguration>
2904                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
2905                <Event>s3:ObjectCreated:*</Event>
2906            </LambdaFunctionConfiguration>
2907        </NotificationConfiguration>"#;
2908        let targets = parse_notification_config(xml);
2909        assert_eq!(targets.len(), 1);
2910        assert_eq!(targets[0].prefix_filter, None);
2911        assert_eq!(targets[0].suffix_filter, None);
2912    }
2913
2914    #[test]
2915    fn test_key_matches_filters() {
2916        // No filters — everything matches
2917        assert!(key_matches_filters("anything", &None, &None));
2918
2919        // Prefix only
2920        assert!(key_matches_filters(
2921            "images/photo.jpg",
2922            &Some("images/".to_string()),
2923            &None
2924        ));
2925        assert!(!key_matches_filters(
2926            "docs/file.txt",
2927            &Some("images/".to_string()),
2928            &None
2929        ));
2930
2931        // Suffix only
2932        assert!(key_matches_filters(
2933            "images/photo.jpg",
2934            &None,
2935            &Some(".jpg".to_string())
2936        ));
2937        assert!(!key_matches_filters(
2938            "images/photo.png",
2939            &None,
2940            &Some(".jpg".to_string())
2941        ));
2942
2943        // Both prefix and suffix
2944        assert!(key_matches_filters(
2945            "images/photo.jpg",
2946            &Some("images/".to_string()),
2947            &Some(".jpg".to_string())
2948        ));
2949        assert!(!key_matches_filters(
2950            "images/photo.png",
2951            &Some("images/".to_string()),
2952            &Some(".jpg".to_string())
2953        ));
2954        assert!(!key_matches_filters(
2955            "docs/photo.jpg",
2956            &Some("images/".to_string()),
2957            &Some(".jpg".to_string())
2958        ));
2959    }
2960
2961    #[test]
2962    fn test_parse_cors_config() {
2963        let xml = r#"<CORSConfiguration>
2964            <CORSRule>
2965                <AllowedOrigin>https://example.com</AllowedOrigin>
2966                <AllowedMethod>GET</AllowedMethod>
2967                <AllowedMethod>PUT</AllowedMethod>
2968                <AllowedHeader>*</AllowedHeader>
2969                <ExposeHeader>x-amz-request-id</ExposeHeader>
2970                <MaxAgeSeconds>3600</MaxAgeSeconds>
2971            </CORSRule>
2972        </CORSConfiguration>"#;
2973        let rules = parse_cors_config(xml);
2974        assert_eq!(rules.len(), 1);
2975        assert_eq!(rules[0].allowed_origins, vec!["https://example.com"]);
2976        assert_eq!(rules[0].allowed_methods, vec!["GET", "PUT"]);
2977        assert_eq!(rules[0].allowed_headers, vec!["*"]);
2978        assert_eq!(rules[0].expose_headers, vec!["x-amz-request-id"]);
2979        assert_eq!(rules[0].max_age_seconds, Some(3600));
2980    }
2981
2982    #[test]
2983    fn test_origin_matches() {
2984        assert!(origin_matches("https://example.com", "https://example.com"));
2985        assert!(origin_matches("https://example.com", "*"));
2986        assert!(origin_matches("https://foo.example.com", "*.example.com"));
2987        assert!(!origin_matches("https://evil.com", "https://example.com"));
2988    }
2989
2990    /// Regression: resolve_object with versionId="null" must match objects
2991    /// whose version_id is either None or Some("null").
2992    #[test]
2993    fn resolve_null_version_matches_both_none_and_null_string() {
2994        use crate::state::S3Bucket;
2995        use bytes::Bytes;
2996        use chrono::Utc;
2997
2998        let mut b = S3Bucket::new("test", "us-east-1", "owner");
2999
3000        // Helper to create a minimal S3Object
3001        let make_obj = |key: &str, vid: Option<&str>| crate::state::S3Object {
3002            key: key.to_string(),
3003            body: crate::state::memory_body(Bytes::from_static(b"x")),
3004            content_type: "text/plain".to_string(),
3005            etag: "\"abc\"".to_string(),
3006            size: 1,
3007            last_modified: Utc::now(),
3008            storage_class: "STANDARD".to_string(),
3009            version_id: vid.map(|s| s.to_string()),
3010            ..Default::default()
3011        };
3012
3013        // Object with version_id = Some("null") (pre-versioning migrated)
3014        let obj = make_obj("file.txt", Some("null"));
3015        b.objects.insert("file.txt".to_string(), obj.clone());
3016        b.object_versions.insert("file.txt".to_string(), vec![obj]);
3017
3018        let null_str = "null".to_string();
3019        let result = resolve_object(&b, "file.txt", Some(&null_str));
3020        assert!(
3021            result.is_ok(),
3022            "versionId=null should match version_id=Some(\"null\")"
3023        );
3024
3025        // Object with version_id = None (true pre-versioning)
3026        let obj2 = make_obj("file2.txt", None);
3027        b.objects.insert("file2.txt".to_string(), obj2.clone());
3028        b.object_versions
3029            .insert("file2.txt".to_string(), vec![obj2]);
3030
3031        let result2 = resolve_object(&b, "file2.txt", Some(&null_str));
3032        assert!(
3033            result2.is_ok(),
3034            "versionId=null should match version_id=None"
3035        );
3036    }
3037
3038    #[test]
3039    fn test_parse_replication_rules() {
3040        let xml = r#"<ReplicationConfiguration>
3041            <Role>arn:aws:iam::role/replication</Role>
3042            <Rule>
3043                <Status>Enabled</Status>
3044                <Filter><Prefix>logs/</Prefix></Filter>
3045                <Destination><Bucket>arn:aws:s3:::dest-bucket</Bucket></Destination>
3046            </Rule>
3047            <Rule>
3048                <Status>Disabled</Status>
3049                <Filter><Prefix></Prefix></Filter>
3050                <Destination><Bucket>arn:aws:s3:::other-bucket</Bucket></Destination>
3051            </Rule>
3052        </ReplicationConfiguration>"#;
3053
3054        let rules = parse_replication_rules(xml);
3055        assert_eq!(rules.len(), 2);
3056        assert_eq!(rules[0].status, "Enabled");
3057        assert_eq!(rules[0].prefix, "logs/");
3058        assert_eq!(rules[0].dest_bucket, "dest-bucket");
3059        assert_eq!(rules[1].status, "Disabled");
3060        assert_eq!(rules[1].prefix, "");
3061        assert_eq!(rules[1].dest_bucket, "other-bucket");
3062    }
3063
3064    #[test]
3065    fn test_parse_normalized_replication_rules() {
3066        // First, normalize the XML like the server does
3067        let input_xml = r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Role>arn:aws:iam::123456789012:role/replication-role</Role><Rule><ID>replicate-all</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Destination><Bucket>arn:aws:s3:::repl-dest</Bucket></Destination></Rule></ReplicationConfiguration>"#;
3068        let normalized = normalize_replication_xml(input_xml);
3069        eprintln!("Normalized XML: {normalized}");
3070        let rules = parse_replication_rules(&normalized);
3071        assert_eq!(rules.len(), 1, "Expected 1 rule, got {}", rules.len());
3072        assert_eq!(rules[0].status, "Enabled");
3073        assert_eq!(rules[0].dest_bucket, "repl-dest");
3074    }
3075
3076    #[test]
3077    fn test_replicate_object() {
3078        use crate::state::{S3Bucket, S3State};
3079
3080        let mut state = S3State::new("123456789012", "us-east-1");
3081
3082        // Create source and destination buckets
3083        let mut src = S3Bucket::new("source", "us-east-1", "owner");
3084        src.versioning = Some("Enabled".to_string());
3085        src.replication_config = Some(
3086            "<ReplicationConfiguration>\
3087             <Rule><Status>Enabled</Status>\
3088             <Filter><Prefix></Prefix></Filter>\
3089             <Destination><Bucket>arn:aws:s3:::destination</Bucket></Destination>\
3090             </Rule></ReplicationConfiguration>"
3091                .to_string(),
3092        );
3093        let obj = S3Object {
3094            key: "test-key".to_string(),
3095            body: crate::state::memory_body(Bytes::from_static(b"hello")),
3096            content_type: "text/plain".to_string(),
3097            etag: "abc".to_string(),
3098            size: 5,
3099            last_modified: Utc::now(),
3100            storage_class: "STANDARD".to_string(),
3101            version_id: Some("v1".to_string()),
3102            ..Default::default()
3103        };
3104        src.objects.insert("test-key".to_string(), obj);
3105        state.buckets.insert("source".to_string(), src);
3106
3107        let dest = S3Bucket::new("destination", "us-east-1", "owner");
3108        state.buckets.insert("destination".to_string(), dest);
3109
3110        replicate_object(&mut state, "source", "test-key");
3111
3112        // Object should now exist in destination
3113        let dest_obj = state
3114            .buckets
3115            .get("destination")
3116            .unwrap()
3117            .objects
3118            .get("test-key");
3119        assert!(dest_obj.is_some());
3120        assert_eq!(
3121            state.read_body(&dest_obj.unwrap().body).unwrap(),
3122            Bytes::from_static(b"hello")
3123        );
3124    }
3125
3126    #[test]
3127    fn cors_header_value_does_not_panic_on_unusual_input() {
3128        // Verify that CORS header value parsing doesn't panic even with unusual strings.
3129        // HeaderValue::from_str rejects non-visible-ASCII, so our unwrap_or_else fallback
3130        // must produce a valid (empty) header value instead of panicking.
3131        let valid_origin = "https://example.com";
3132        let result: Result<http::HeaderValue, _> = valid_origin.parse();
3133        assert!(result.is_ok());
3134
3135        // Non-ASCII would fail .parse() for HeaderValue; verify fallback works
3136        let bad_origin = "https://ex\x01ample.com";
3137        let result: Result<http::HeaderValue, _> = bad_origin.parse();
3138        assert!(result.is_err());
3139        // Our production code uses unwrap_or_else to return empty HeaderValue
3140        let fallback = bad_origin
3141            .parse()
3142            .unwrap_or_else(|_| http::HeaderValue::from_static(""));
3143        assert_eq!(fallback, "");
3144    }
3145
3146    // ────────────────────────────────────────────────────────────────
3147    // Service-level tests for tags / multipart / config submodules.
3148    //
3149    // Each helper below builds an isolated S3Service with the in-memory
3150    // store so the submodule handlers can be driven directly without a
3151    // running Axum router.
3152    // ────────────────────────────────────────────────────────────────
3153
3154    use crate::state::{S3Bucket, S3Object};
3155    use bytes::Bytes;
3156    use fakecloud_core::delivery::DeliveryBus;
3157    use fakecloud_core::service::{AwsRequest, AwsServiceError};
3158    use http::{HeaderMap, Method, StatusCode};
3159    use parking_lot::RwLock;
3160    use std::collections::HashMap;
3161    use std::sync::Arc;
3162
3163    fn make_service() -> S3Service {
3164        let state: SharedS3State = Arc::new(RwLock::new(
3165            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3166        ));
3167        S3Service::new(state, Arc::new(DeliveryBus::new()))
3168    }
3169
3170    fn seed_bucket(svc: &S3Service, name: &str) {
3171        let mut mas = svc.state.write();
3172        let state = mas.default_mut();
3173        state
3174            .buckets
3175            .insert(name.to_string(), S3Bucket::new(name, "us-east-1", "owner"));
3176    }
3177
3178    fn seed_object(svc: &S3Service, bucket: &str, key: &str, body: &[u8]) {
3179        let mut mas = svc.state.write();
3180        let state = mas.default_mut();
3181        let b = state.buckets.get_mut(bucket).expect("bucket seeded");
3182        let mut obj = S3Object {
3183            key: key.to_string(),
3184            body: fakecloud_persistence::BodyRef::Memory(Bytes::copy_from_slice(body)),
3185            content_type: "application/octet-stream".to_string(),
3186            etag: format!("\"{}\"", compute_md5(body)),
3187            size: body.len() as u64,
3188            last_modified: chrono::Utc::now(),
3189            ..Default::default()
3190        };
3191        obj.metadata.insert("version".to_string(), "1".to_string());
3192        b.objects.insert(key.to_string(), obj);
3193    }
3194
3195    fn make_request(method: Method, path: &str, query: &[(&str, &str)], body: &[u8]) -> AwsRequest {
3196        let segments: Vec<String> = path
3197            .trim_start_matches('/')
3198            .split('/')
3199            .filter(|s| !s.is_empty())
3200            .map(|s| s.to_string())
3201            .collect();
3202        let query_params: HashMap<String, String> = query
3203            .iter()
3204            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
3205            .collect();
3206        let raw_query = query
3207            .iter()
3208            .map(|(k, v)| format!("{k}={v}"))
3209            .collect::<Vec<_>>()
3210            .join("&");
3211        // Wire body_stream from the same bytes so streaming-only handlers
3212        // (put_object, upload_part) can consume it. Buffered handlers
3213        // (put_object_tagging, put_object_acl, …) read `body` directly
3214        // and ignore the stream.
3215        let stream_body =
3216            fakecloud_core::service::RequestBodyStream::from(Bytes::copy_from_slice(body));
3217        AwsRequest {
3218            service: "s3".to_string(),
3219            action: String::new(),
3220            region: "us-east-1".to_string(),
3221            account_id: "123456789012".to_string(),
3222            request_id: "test-req".to_string(),
3223            headers: HeaderMap::new(),
3224            query_params,
3225            body: Bytes::copy_from_slice(body),
3226            body_stream: parking_lot::Mutex::new(Some(stream_body)),
3227            path_segments: segments,
3228            raw_path: path.to_string(),
3229            raw_query,
3230            method,
3231            is_query_protocol: false,
3232            access_key_id: None,
3233            principal: None,
3234        }
3235    }
3236
3237    fn assert_aws_err(
3238        result: Result<AwsResponse, AwsServiceError>,
3239        expect_code: &str,
3240    ) -> AwsServiceError {
3241        let err = match result {
3242            Ok(_) => panic!("expected error, got Ok response"),
3243            Err(e) => e,
3244        };
3245        match &err {
3246            AwsServiceError::AwsError { code, .. } => {
3247                assert_eq!(code, expect_code, "wrong error code");
3248            }
3249            other => panic!("expected AwsError, got {other:?}"),
3250        }
3251        err
3252    }
3253
3254    // ── Tags (service/tags.rs) ───────────────────────────────────────
3255
3256    #[test]
3257    fn get_object_tagging_on_object_returns_xml_tagset() {
3258        let svc = make_service();
3259        seed_bucket(&svc, "b");
3260        seed_object(&svc, "b", "k", b"hello");
3261        {
3262            let mut mas = svc.state.write();
3263            let obj = mas
3264                .default_mut()
3265                .buckets
3266                .get_mut("b")
3267                .unwrap()
3268                .objects
3269                .get_mut("k")
3270                .unwrap();
3271            obj.tags.insert("env".to_string(), "prod".to_string());
3272            obj.tags.insert("team".to_string(), "plat".to_string());
3273        }
3274
3275        let req = make_request(Method::GET, "/b/k", &[("tagging", "")], b"");
3276        let resp = svc
3277            .get_object_tagging("123456789012", &req, "b", "k")
3278            .unwrap();
3279        assert_eq!(resp.status, StatusCode::OK);
3280        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3281        assert!(body.contains("<Tag><Key>env</Key><Value>prod</Value></Tag>"));
3282        assert!(body.contains("<Tag><Key>team</Key><Value>plat</Value></Tag>"));
3283    }
3284
3285    #[test]
3286    fn get_object_tagging_missing_bucket_errors() {
3287        let svc = make_service();
3288        let req = make_request(Method::GET, "/nope/k", &[("tagging", "")], b"");
3289        assert_aws_err(
3290            svc.get_object_tagging("123456789012", &req, "nope", "k"),
3291            "NoSuchBucket",
3292        );
3293    }
3294
3295    #[test]
3296    fn put_object_tagging_rejects_aws_prefixed_key() {
3297        let svc = make_service();
3298        seed_bucket(&svc, "b");
3299        seed_object(&svc, "b", "k", b"x");
3300
3301        let xml = r#"<Tagging><TagSet><Tag><Key>aws:internal</Key><Value>v</Value></Tag></TagSet></Tagging>"#;
3302        let req = make_request(Method::PUT, "/b/k", &[("tagging", "")], xml.as_bytes());
3303        assert_aws_err(
3304            svc.put_object_tagging("123456789012", &req, "b", "k"),
3305            "InvalidTag",
3306        );
3307    }
3308
3309    #[test]
3310    fn put_object_tagging_rejects_too_many_tags() {
3311        let svc = make_service();
3312        seed_bucket(&svc, "b");
3313        seed_object(&svc, "b", "k", b"x");
3314
3315        let mut xml = String::from("<Tagging><TagSet>");
3316        for i in 0..11 {
3317            xml.push_str(&format!("<Tag><Key>k{i}</Key><Value>v</Value></Tag>"));
3318        }
3319        xml.push_str("</TagSet></Tagging>");
3320        let req = make_request(Method::PUT, "/b/k", &[("tagging", "")], xml.as_bytes());
3321        assert_aws_err(
3322            svc.put_object_tagging("123456789012", &req, "b", "k"),
3323            "BadRequest",
3324        );
3325    }
3326
3327    #[test]
3328    fn put_object_tagging_on_missing_object_errors() {
3329        let svc = make_service();
3330        seed_bucket(&svc, "b");
3331        let xml =
3332            r#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#;
3333        let req = make_request(
3334            Method::PUT,
3335            "/b/missing",
3336            &[("tagging", "")],
3337            xml.as_bytes(),
3338        );
3339        assert_aws_err(
3340            svc.put_object_tagging("123456789012", &req, "b", "missing"),
3341            "NoSuchKey",
3342        );
3343    }
3344
3345    #[test]
3346    fn put_object_tagging_replaces_existing_tags() {
3347        let svc = make_service();
3348        seed_bucket(&svc, "b");
3349        seed_object(&svc, "b", "k", b"x");
3350        {
3351            let mut mas = svc.state.write();
3352            let obj = mas
3353                .default_mut()
3354                .buckets
3355                .get_mut("b")
3356                .unwrap()
3357                .objects
3358                .get_mut("k")
3359                .unwrap();
3360            obj.tags.insert("old".to_string(), "gone".to_string());
3361        }
3362
3363        let xml =
3364            r#"<Tagging><TagSet><Tag><Key>new</Key><Value>here</Value></Tag></TagSet></Tagging>"#;
3365        let req = make_request(Method::PUT, "/b/k", &[("tagging", "")], xml.as_bytes());
3366        let resp = svc
3367            .put_object_tagging("123456789012", &req, "b", "k")
3368            .unwrap();
3369        assert_eq!(resp.status, StatusCode::OK);
3370
3371        let __mas = svc.state.read();
3372        let state = __mas.default_ref();
3373        let tags = &state
3374            .buckets
3375            .get("b")
3376            .unwrap()
3377            .objects
3378            .get("k")
3379            .unwrap()
3380            .tags;
3381        assert_eq!(tags.get("new").map(String::as_str), Some("here"));
3382        assert!(!tags.contains_key("old"));
3383    }
3384
3385    #[test]
3386    fn delete_object_tagging_clears_tags() {
3387        let svc = make_service();
3388        seed_bucket(&svc, "b");
3389        seed_object(&svc, "b", "k", b"x");
3390        {
3391            let mut mas = svc.state.write();
3392            let obj = mas
3393                .default_mut()
3394                .buckets
3395                .get_mut("b")
3396                .unwrap()
3397                .objects
3398                .get_mut("k")
3399                .unwrap();
3400            obj.tags.insert("env".to_string(), "prod".to_string());
3401        }
3402
3403        let resp = svc.delete_object_tagging("123456789012", "b", "k").unwrap();
3404        assert_eq!(resp.status, StatusCode::NO_CONTENT);
3405        let __mas = svc.state.read();
3406        let state = __mas.default_ref();
3407        assert!(state
3408            .buckets
3409            .get("b")
3410            .unwrap()
3411            .objects
3412            .get("k")
3413            .unwrap()
3414            .tags
3415            .is_empty());
3416    }
3417
3418    #[test]
3419    fn delete_object_tagging_missing_key_errors() {
3420        let svc = make_service();
3421        seed_bucket(&svc, "b");
3422        assert_aws_err(
3423            svc.delete_object_tagging("123456789012", "b", "gone"),
3424            "NoSuchKey",
3425        );
3426    }
3427
3428    // ── Multipart (service/multipart.rs) ─────────────────────────────
3429
3430    fn initiate_mpu(svc: &S3Service, bucket: &str, key: &str) -> String {
3431        let req = make_request(
3432            Method::POST,
3433            &format!("/{bucket}/{key}"),
3434            &[("uploads", "")],
3435            b"",
3436        );
3437        let resp = svc
3438            .create_multipart_upload("123456789012", &req, bucket, key)
3439            .unwrap();
3440        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3441        let start = body.find("<UploadId>").unwrap() + "<UploadId>".len();
3442        let end = body.find("</UploadId>").unwrap();
3443        body[start..end].to_string()
3444    }
3445
3446    #[test]
3447    fn create_multipart_upload_records_upload_in_state() {
3448        let svc = make_service();
3449        seed_bucket(&svc, "b");
3450        let upload_id = initiate_mpu(&svc, "b", "big.bin");
3451        let __mas = svc.state.read();
3452        let state = __mas.default_ref();
3453        assert!(state
3454            .buckets
3455            .get("b")
3456            .unwrap()
3457            .multipart_uploads
3458            .contains_key(&upload_id));
3459    }
3460
3461    #[test]
3462    fn create_multipart_upload_rejects_acl_and_grants_combo() {
3463        let svc = make_service();
3464        seed_bucket(&svc, "b");
3465        let mut req = make_request(Method::POST, "/b/k", &[("uploads", "")], b"");
3466        req.headers.insert("x-amz-acl", "private".parse().unwrap());
3467        req.headers
3468            .insert("x-amz-grant-read", "id=owner".parse().unwrap());
3469        assert_aws_err(
3470            svc.create_multipart_upload("123456789012", &req, "b", "k"),
3471            "InvalidRequest",
3472        );
3473    }
3474
3475    #[test]
3476    fn create_multipart_upload_missing_bucket_errors() {
3477        let svc = make_service();
3478        let req = make_request(Method::POST, "/ghost/k", &[("uploads", "")], b"");
3479        assert_aws_err(
3480            svc.create_multipart_upload("123456789012", &req, "ghost", "k"),
3481            "NoSuchBucket",
3482        );
3483    }
3484
3485    #[tokio::test]
3486    async fn upload_part_rejects_invalid_part_number() {
3487        let svc = make_service();
3488        seed_bucket(&svc, "b");
3489        let upload_id = initiate_mpu(&svc, "b", "k");
3490
3491        // part_number < 1 is masked as NoSuchUpload (matching AWS behavior).
3492        let req = make_request(Method::PUT, "/b/k", &[("partNumber", "0")], b"body");
3493        assert_aws_err(
3494            svc.upload_part("123456789012", &req, "b", "k", &upload_id, 0)
3495                .await,
3496            "NoSuchUpload",
3497        );
3498
3499        // part_number > 10000 returns InvalidArgument.
3500        let req2 = make_request(Method::PUT, "/b/k", &[("partNumber", "10001")], b"body");
3501        assert_aws_err(
3502            svc.upload_part("123456789012", &req2, "b", "k", &upload_id, 10_001)
3503                .await,
3504            "InvalidArgument",
3505        );
3506    }
3507
3508    #[tokio::test]
3509    async fn upload_part_missing_upload_errors() {
3510        let svc = make_service();
3511        seed_bucket(&svc, "b");
3512        let req = make_request(Method::PUT, "/b/k", &[("partNumber", "1")], b"body");
3513        assert_aws_err(
3514            svc.upload_part("123456789012", &req, "b", "k", "not-an-upload", 1)
3515                .await,
3516            "NoSuchUpload",
3517        );
3518    }
3519
3520    #[tokio::test]
3521    async fn mpu_full_lifecycle_creates_object() {
3522        let svc = make_service();
3523        seed_bucket(&svc, "b");
3524        let upload_id = initiate_mpu(&svc, "b", "k");
3525
3526        // Single-part upload — CompleteMultipartUpload's MIN_PART_SIZE check
3527        // only applies to non-last parts, so a single part of any size works.
3528        let part_body = b"hello";
3529        let req = make_request(Method::PUT, "/b/k", &[("partNumber", "1")], part_body);
3530        let resp = svc
3531            .upload_part("123456789012", &req, "b", "k", &upload_id, 1)
3532            .await
3533            .unwrap();
3534        let etag = resp
3535            .headers
3536            .get("etag")
3537            .unwrap()
3538            .to_str()
3539            .unwrap()
3540            .to_string();
3541
3542        let complete_xml = format!(
3543            r#"<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>{etag}</ETag></Part></CompleteMultipartUpload>"#,
3544        );
3545        let complete_req = make_request(
3546            Method::POST,
3547            "/b/k",
3548            &[("uploadId", &upload_id)],
3549            complete_xml.as_bytes(),
3550        );
3551        let resp = svc
3552            .complete_multipart_upload("123456789012", &complete_req, "b", "k", &upload_id)
3553            .unwrap();
3554        assert_eq!(resp.status, StatusCode::OK);
3555
3556        let __mas = svc.state.read();
3557        let state = __mas.default_ref();
3558        let bucket = state.buckets.get("b").unwrap();
3559        let obj = bucket.objects.get("k").expect("object materialized");
3560        assert_eq!(obj.size, part_body.len() as u64);
3561        assert!(!bucket.multipart_uploads.contains_key(&upload_id));
3562    }
3563
3564    #[tokio::test]
3565    async fn mpu_complete_rejects_small_non_last_part() {
3566        let svc = make_service();
3567        seed_bucket(&svc, "b");
3568        let upload_id = initiate_mpu(&svc, "b", "k");
3569
3570        for n in 1..=2 {
3571            let body = format!("part{n}");
3572            let req = make_request(
3573                Method::PUT,
3574                "/b/k",
3575                &[("partNumber", &n.to_string())],
3576                body.as_bytes(),
3577            );
3578            svc.upload_part("123456789012", &req, "b", "k", &upload_id, n)
3579                .await
3580                .unwrap();
3581        }
3582
3583        // Grab the etags from state.
3584        let (etag1, etag2) = {
3585            let __mas = svc.state.read();
3586            let state = __mas.default_ref();
3587            let parts = &state
3588                .buckets
3589                .get("b")
3590                .unwrap()
3591                .multipart_uploads
3592                .get(&upload_id)
3593                .unwrap()
3594                .parts;
3595            (
3596                parts.get(&1).unwrap().etag.clone(),
3597                parts.get(&2).unwrap().etag.clone(),
3598            )
3599        };
3600
3601        let complete_xml = format!(
3602            r#"<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>{etag1}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>{etag2}</ETag></Part></CompleteMultipartUpload>"#,
3603        );
3604        let complete_req = make_request(
3605            Method::POST,
3606            "/b/k",
3607            &[("uploadId", &upload_id)],
3608            complete_xml.as_bytes(),
3609        );
3610        assert_aws_err(
3611            svc.complete_multipart_upload("123456789012", &complete_req, "b", "k", &upload_id),
3612            "EntityTooSmall",
3613        );
3614    }
3615
3616    #[test]
3617    fn abort_multipart_upload_removes_upload() {
3618        let svc = make_service();
3619        seed_bucket(&svc, "b");
3620        let upload_id = initiate_mpu(&svc, "b", "k");
3621        let resp = svc
3622            .abort_multipart_upload("123456789012", "b", "k", &upload_id)
3623            .unwrap();
3624        assert_eq!(resp.status, StatusCode::NO_CONTENT);
3625        let __mas = svc.state.read();
3626        let state = __mas.default_ref();
3627        assert!(!state
3628            .buckets
3629            .get("b")
3630            .unwrap()
3631            .multipart_uploads
3632            .contains_key(&upload_id));
3633    }
3634
3635    #[test]
3636    fn abort_multipart_upload_unknown_id_errors() {
3637        let svc = make_service();
3638        seed_bucket(&svc, "b");
3639        assert_aws_err(
3640            svc.abort_multipart_upload("123456789012", "b", "k", "no-such"),
3641            "NoSuchUpload",
3642        );
3643    }
3644
3645    #[test]
3646    fn list_multipart_uploads_includes_all_in_flight() {
3647        let svc = make_service();
3648        seed_bucket(&svc, "b");
3649        let u1 = initiate_mpu(&svc, "b", "a");
3650        let u2 = initiate_mpu(&svc, "b", "b");
3651        let resp = svc.list_multipart_uploads("123456789012", "b").unwrap();
3652        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3653        assert!(body.contains(&u1));
3654        assert!(body.contains(&u2));
3655    }
3656
3657    #[tokio::test]
3658    async fn list_parts_after_upload_returns_parts() {
3659        let svc = make_service();
3660        seed_bucket(&svc, "b");
3661        let upload_id = initiate_mpu(&svc, "b", "k");
3662        let req = make_request(Method::PUT, "/b/k", &[("partNumber", "1")], b"data");
3663        svc.upload_part("123456789012", &req, "b", "k", &upload_id, 1)
3664            .await
3665            .unwrap();
3666
3667        let list_req = make_request(Method::GET, "/b/k", &[("uploadId", &upload_id)], b"");
3668        let resp = svc
3669            .list_parts("123456789012", &list_req, "b", "k", &upload_id)
3670            .unwrap();
3671        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3672        assert!(body.contains("<PartNumber>1</PartNumber>"));
3673    }
3674
3675    // ── Config (service/config.rs) ───────────────────────────────────
3676
3677    #[test]
3678    fn bucket_encryption_put_get_delete_round_trip() {
3679        let svc = make_service();
3680        seed_bucket(&svc, "b");
3681
3682        let xml = r#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#;
3683        let req = make_request(Method::PUT, "/b", &[("encryption", "")], xml.as_bytes());
3684        let resp = svc
3685            .put_bucket_encryption("123456789012", &req, "b")
3686            .unwrap();
3687        assert_eq!(resp.status, StatusCode::OK);
3688
3689        // Normalized body should include BucketKeyEnabled=false.
3690        let get = svc.get_bucket_encryption("123456789012", "b").unwrap();
3691        let body = std::str::from_utf8(get.body.expect_bytes()).unwrap();
3692        assert!(body.contains("AES256"));
3693        assert!(body.contains("<BucketKeyEnabled>false</BucketKeyEnabled>"));
3694
3695        let del = svc.delete_bucket_encryption("123456789012", "b").unwrap();
3696        assert_eq!(del.status, StatusCode::NO_CONTENT);
3697        assert_aws_err(
3698            svc.get_bucket_encryption("123456789012", "b"),
3699            "ServerSideEncryptionConfigurationNotFoundError",
3700        );
3701    }
3702
3703    #[test]
3704    fn bucket_policy_rejects_malformed_json() {
3705        let svc = make_service();
3706        seed_bucket(&svc, "b");
3707        let req = make_request(Method::PUT, "/b", &[("policy", "")], b"not-json");
3708        assert_aws_err(
3709            svc.put_bucket_policy("123456789012", &req, "b"),
3710            "MalformedPolicy",
3711        );
3712    }
3713
3714    #[test]
3715    fn bucket_policy_put_get_delete_round_trip() {
3716        let svc = make_service();
3717        seed_bucket(&svc, "b");
3718
3719        let body = br#"{"Version":"2012-10-17","Statement":[]}"#;
3720        let put_req = make_request(Method::PUT, "/b", &[("policy", "")], body);
3721        let resp = svc
3722            .put_bucket_policy("123456789012", &put_req, "b")
3723            .unwrap();
3724        assert_eq!(resp.status, StatusCode::NO_CONTENT);
3725
3726        let get = svc.get_bucket_policy("123456789012", "b").unwrap();
3727        assert_eq!(get.body.expect_bytes(), body);
3728
3729        let del = svc.delete_bucket_policy("123456789012", "b").unwrap();
3730        assert_eq!(del.status, StatusCode::NO_CONTENT);
3731        assert_aws_err(
3732            svc.get_bucket_policy("123456789012", "b"),
3733            "NoSuchBucketPolicy",
3734        );
3735    }
3736
3737    #[test]
3738    fn bucket_lifecycle_empty_rules_clears_config() {
3739        let svc = make_service();
3740        seed_bucket(&svc, "b");
3741        {
3742            let mut __mas = svc.state.write();
3743            let state = __mas.default_mut();
3744            state.buckets.get_mut("b").unwrap().lifecycle_config = Some("placeholder".to_string());
3745        }
3746        let req = make_request(
3747            Method::PUT,
3748            "/b",
3749            &[("lifecycle", "")],
3750            b"<LifecycleConfiguration></LifecycleConfiguration>",
3751        );
3752        svc.put_bucket_lifecycle("123456789012", &req, "b").unwrap();
3753        let __mas = svc.state.read();
3754        let state = __mas.default_ref();
3755        assert!(state.buckets.get("b").unwrap().lifecycle_config.is_none());
3756    }
3757
3758    #[test]
3759    fn bucket_cors_put_get_delete_round_trip() {
3760        let svc = make_service();
3761        seed_bucket(&svc, "b");
3762        let xml = br#"<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>"#;
3763        let req = make_request(Method::PUT, "/b", &[("cors", "")], xml);
3764        svc.put_bucket_cors("123456789012", &req, "b").unwrap();
3765        let got = svc.get_bucket_cors("123456789012", "b").unwrap();
3766        assert!(std::str::from_utf8(got.body.expect_bytes())
3767            .unwrap()
3768            .contains("CORSConfiguration"));
3769        svc.delete_bucket_cors("123456789012", "b").unwrap();
3770        assert_aws_err(
3771            svc.get_bucket_cors("123456789012", "b"),
3772            "NoSuchCORSConfiguration",
3773        );
3774    }
3775
3776    #[test]
3777    fn bucket_versioning_put_and_get() {
3778        let svc = make_service();
3779        seed_bucket(&svc, "b");
3780        let req = make_request(
3781            Method::PUT,
3782            "/b",
3783            &[("versioning", "")],
3784            b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
3785        );
3786        svc.put_bucket_versioning("123456789012", &req, "b")
3787            .unwrap();
3788
3789        let __mas = svc.state.read();
3790        let state = __mas.default_ref();
3791        assert_eq!(
3792            state.buckets.get("b").unwrap().versioning.as_deref(),
3793            Some("Enabled")
3794        );
3795        drop(__mas);
3796
3797        let resp = svc.get_bucket_versioning("123456789012", "b").unwrap();
3798        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3799        assert!(body.contains("<Status>Enabled</Status>"));
3800    }
3801
3802    #[test]
3803    fn bucket_tagging_put_get_delete_round_trip() {
3804        let svc = make_service();
3805        seed_bucket(&svc, "b");
3806        let req = make_request(
3807            Method::PUT,
3808            "/b",
3809            &[("tagging", "")],
3810            br#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#,
3811        );
3812        svc.put_bucket_tagging("123456789012", &req, "b").unwrap();
3813        let get_req = make_request(Method::GET, "/b", &[("tagging", "")], b"");
3814        let got = svc
3815            .get_bucket_tagging("123456789012", &get_req, "b")
3816            .unwrap();
3817        assert!(std::str::from_utf8(got.body.expect_bytes())
3818            .unwrap()
3819            .contains("<Key>env</Key>"));
3820        let del_req = make_request(Method::DELETE, "/b", &[("tagging", "")], b"");
3821        svc.delete_bucket_tagging("123456789012", &del_req, "b")
3822            .unwrap();
3823        assert_aws_err(
3824            svc.get_bucket_tagging("123456789012", &get_req, "b"),
3825            "NoSuchTagSet",
3826        );
3827    }
3828
3829    #[test]
3830    fn bucket_accelerate_rejects_invalid_status() {
3831        let svc = make_service();
3832        seed_bucket(&svc, "b");
3833        let req = make_request(
3834            Method::PUT,
3835            "/b",
3836            &[("accelerate", "")],
3837            b"<AccelerateConfiguration><Status>Bogus</Status></AccelerateConfiguration>",
3838        );
3839        assert_aws_err(
3840            svc.put_bucket_accelerate("123456789012", &req, "b"),
3841            "MalformedXML",
3842        );
3843    }
3844
3845    #[test]
3846    fn bucket_accelerate_enabled_is_persisted() {
3847        let svc = make_service();
3848        seed_bucket(&svc, "b");
3849        let req = make_request(
3850            Method::PUT,
3851            "/b",
3852            &[("accelerate", "")],
3853            b"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>",
3854        );
3855        svc.put_bucket_accelerate("123456789012", &req, "b")
3856            .unwrap();
3857        let got = svc.get_bucket_accelerate("123456789012", "b").unwrap();
3858        assert!(std::str::from_utf8(got.body.expect_bytes())
3859            .unwrap()
3860            .contains("<Status>Enabled</Status>"));
3861    }
3862
3863    #[test]
3864    fn public_access_block_put_get_delete_round_trip() {
3865        let svc = make_service();
3866        seed_bucket(&svc, "b");
3867        let body = br#"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>true</IgnorePublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><RestrictPublicBuckets>true</RestrictPublicBuckets></PublicAccessBlockConfiguration>"#;
3868        let req = make_request(Method::PUT, "/b", &[("publicAccessBlock", "")], body);
3869        svc.put_public_access_block("123456789012", &req, "b")
3870            .unwrap();
3871        let got = svc.get_public_access_block("123456789012", "b").unwrap();
3872        assert!(std::str::from_utf8(got.body.expect_bytes())
3873            .unwrap()
3874            .contains("<BlockPublicAcls>true</BlockPublicAcls>"));
3875        svc.delete_public_access_block("123456789012", "b").unwrap();
3876        assert_aws_err(
3877            svc.get_public_access_block("123456789012", "b"),
3878            "NoSuchPublicAccessBlockConfiguration",
3879        );
3880    }
3881
3882    #[test]
3883    fn bucket_website_put_get_delete_round_trip() {
3884        let svc = make_service();
3885        seed_bucket(&svc, "b");
3886        let req = make_request(
3887            Method::PUT,
3888            "/b",
3889            &[("website", "")],
3890            b"<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>",
3891        );
3892        svc.put_bucket_website("123456789012", &req, "b").unwrap();
3893        let got = svc.get_bucket_website("123456789012", "b").unwrap();
3894        assert!(std::str::from_utf8(got.body.expect_bytes())
3895            .unwrap()
3896            .contains("<Suffix>index.html</Suffix>"));
3897        svc.delete_bucket_website("123456789012", "b").unwrap();
3898        assert_aws_err(
3899            svc.get_bucket_website("123456789012", "b"),
3900            "NoSuchWebsiteConfiguration",
3901        );
3902    }
3903
3904    #[test]
3905    fn bucket_replication_requires_existing_bucket() {
3906        let svc = make_service();
3907        let req = make_request(Method::PUT, "/nope", &[("replication", "")], b"<x/>");
3908        assert_aws_err(
3909            svc.put_bucket_replication("123456789012", &req, "nope"),
3910            "NoSuchBucket",
3911        );
3912    }
3913
3914    #[test]
3915    fn bucket_ownership_controls_put_get_delete_round_trip() {
3916        let svc = make_service();
3917        seed_bucket(&svc, "b");
3918        let req = make_request(
3919            Method::PUT,
3920            "/b",
3921            &[("ownershipControls", "")],
3922            b"<OwnershipControls><Rule><ObjectOwnership>BucketOwnerEnforced</ObjectOwnership></Rule></OwnershipControls>",
3923        );
3924        svc.put_bucket_ownership_controls("123456789012", &req, "b")
3925            .unwrap();
3926        let got = svc
3927            .get_bucket_ownership_controls("123456789012", "b")
3928            .unwrap();
3929        assert!(std::str::from_utf8(got.body.expect_bytes())
3930            .unwrap()
3931            .contains("BucketOwnerEnforced"));
3932        svc.delete_bucket_ownership_controls("123456789012", "b")
3933            .unwrap();
3934        assert_aws_err(
3935            svc.get_bucket_ownership_controls("123456789012", "b"),
3936            "OwnershipControlsNotFoundError",
3937        );
3938    }
3939
3940    // ── Error branch tests: object operations ──
3941
3942    #[test]
3943    fn get_object_nonexistent_bucket() {
3944        let svc = make_service();
3945        let req = make_request(Method::GET, "/no-bucket/key", &[], b"");
3946        assert_aws_err(
3947            svc.get_object("123456789012", &req, "no-bucket", "key"),
3948            "NoSuchBucket",
3949        );
3950    }
3951
3952    #[test]
3953    fn get_object_nonexistent_key() {
3954        let svc = make_service();
3955        seed_bucket(&svc, "b");
3956        let req = make_request(Method::GET, "/b/missing", &[], b"");
3957        assert_aws_err(
3958            svc.get_object("123456789012", &req, "b", "missing"),
3959            "NoSuchKey",
3960        );
3961    }
3962
3963    #[tokio::test]
3964    async fn put_object_key_too_long() {
3965        let svc = make_service();
3966        seed_bucket(&svc, "b");
3967        let long_key = "x".repeat(1025);
3968        let req = make_request(Method::PUT, &format!("/b/{long_key}"), &[], b"data");
3969        assert_aws_err(
3970            svc.put_object("123456789012", &req, "b", &long_key).await,
3971            "KeyTooLongError",
3972        );
3973    }
3974
3975    #[tokio::test]
3976    async fn put_object_with_aws_tag_prefix() {
3977        let svc = make_service();
3978        seed_bucket(&svc, "b");
3979        let mut req = make_request(Method::PUT, "/b/tagged", &[], b"data");
3980        req.headers
3981            .insert("x-amz-tagging", "aws:reserved=nope".parse().unwrap());
3982        assert_aws_err(
3983            svc.put_object("123456789012", &req, "b", "tagged").await,
3984            "InvalidTag",
3985        );
3986    }
3987
3988    #[tokio::test]
3989    async fn put_object_acl_and_grant_conflict() {
3990        let svc = make_service();
3991        seed_bucket(&svc, "b");
3992        let mut req = make_request(Method::PUT, "/b/conflict", &[], b"data");
3993        req.headers
3994            .insert("x-amz-acl", "public-read".parse().unwrap());
3995        req.headers
3996            .insert("x-amz-grant-read", "id=abc123".parse().unwrap());
3997        assert_aws_err(
3998            svc.put_object("123456789012", &req, "b", "conflict").await,
3999            "InvalidRequest",
4000        );
4001    }
4002
4003    #[test]
4004    fn head_object_nonexistent_key() {
4005        let svc = make_service();
4006        seed_bucket(&svc, "b");
4007        let req = make_request(Method::HEAD, "/b/missing", &[], b"");
4008        assert_aws_err(
4009            svc.head_object("123456789012", &req, "b", "missing"),
4010            "NoSuchKey",
4011        );
4012    }
4013
4014    #[test]
4015    fn head_object_nonexistent_bucket() {
4016        let svc = make_service();
4017        let req = make_request(Method::HEAD, "/nope/key", &[], b"");
4018        assert_aws_err(
4019            svc.head_object("123456789012", &req, "nope", "key"),
4020            "NoSuchBucket",
4021        );
4022    }
4023
4024    #[test]
4025    fn delete_object_nonexistent_bucket() {
4026        let svc = make_service();
4027        let req = make_request(Method::DELETE, "/nope/key", &[], b"");
4028        assert_aws_err(
4029            svc.delete_object("123456789012", &req, "nope", "key"),
4030            "NoSuchBucket",
4031        );
4032    }
4033
4034    #[test]
4035    fn copy_object_source_not_found() {
4036        let svc = make_service();
4037        seed_bucket(&svc, "src-b");
4038        seed_bucket(&svc, "dst-b");
4039        let mut req = make_request(Method::PUT, "/dst-b/copied", &[], b"");
4040        req.headers
4041            .insert("x-amz-copy-source", "src-b/nonexistent".parse().unwrap());
4042        assert_aws_err(
4043            svc.copy_object("123456789012", &req, "dst-b", "copied"),
4044            "NoSuchKey",
4045        );
4046    }
4047
4048    #[test]
4049    fn copy_object_source_bucket_not_found() {
4050        let svc = make_service();
4051        seed_bucket(&svc, "dst-b2");
4052        let mut req = make_request(Method::PUT, "/dst-b2/copied", &[], b"");
4053        req.headers
4054            .insert("x-amz-copy-source", "nope-bucket/key".parse().unwrap());
4055        assert_aws_err(
4056            svc.copy_object("123456789012", &req, "dst-b2", "copied"),
4057            "NoSuchBucket",
4058        );
4059    }
4060
4061    #[test]
4062    fn list_objects_v2_nonexistent_bucket() {
4063        let svc = make_service();
4064        let req = make_request(Method::GET, "/nope", &[("list-type", "2")], b"");
4065        assert_aws_err(
4066            svc.list_objects_v2("123456789012", &req, "nope"),
4067            "NoSuchBucket",
4068        );
4069    }
4070
4071    #[test]
4072    fn list_objects_v2_empty_continuation_token() {
4073        let svc = make_service();
4074        seed_bucket(&svc, "b");
4075        let req = make_request(
4076            Method::GET,
4077            "/b",
4078            &[("list-type", "2"), ("continuation-token", "")],
4079            b"",
4080        );
4081        assert_aws_err(
4082            svc.list_objects_v2("123456789012", &req, "b"),
4083            "InvalidArgument",
4084        );
4085    }
4086
4087    #[test]
4088    fn list_objects_v1_nonexistent_bucket() {
4089        let svc = make_service();
4090        let req = make_request(Method::GET, "/nope", &[], b"");
4091        assert_aws_err(
4092            svc.list_objects_v1("123456789012", &req, "nope"),
4093            "NoSuchBucket",
4094        );
4095    }
4096
4097    #[test]
4098    fn list_object_versions_nonexistent_bucket() {
4099        let svc = make_service();
4100        let req = make_request(Method::GET, "/nope", &[("versions", "")], b"");
4101        assert_aws_err(
4102            svc.list_object_versions("123456789012", &req, "nope"),
4103            "NoSuchBucket",
4104        );
4105    }
4106
4107    // ── Error branch tests: multipart operations ──
4108
4109    #[test]
4110    fn create_multipart_nonexistent_bucket() {
4111        let svc = make_service();
4112        let req = make_request(Method::POST, "/nope/key", &[("uploads", "")], b"");
4113        assert_aws_err(
4114            svc.create_multipart_upload("123456789012", &req, "nope", "key"),
4115            "NoSuchBucket",
4116        );
4117    }
4118
4119    #[tokio::test]
4120    async fn upload_part_nonexistent_upload() {
4121        let svc = make_service();
4122        seed_bucket(&svc, "b");
4123        let req = make_request(
4124            Method::PUT,
4125            "/b/key",
4126            &[("uploadId", "bogus"), ("partNumber", "1")],
4127            b"data",
4128        );
4129        assert_aws_err(
4130            svc.upload_part("123456789012", &req, "b", "key", "bogus", 1)
4131                .await,
4132            "NoSuchUpload",
4133        );
4134    }
4135
4136    #[test]
4137    fn complete_multipart_nonexistent_upload() {
4138        let svc = make_service();
4139        seed_bucket(&svc, "b");
4140        let req = make_request(
4141            Method::POST,
4142            "/b/key",
4143            &[("uploadId", "bogus")],
4144            b"<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"abc\"</ETag></Part></CompleteMultipartUpload>",
4145        );
4146        assert_aws_err(
4147            svc.complete_multipart_upload("123456789012", &req, "b", "key", "bogus"),
4148            "NoSuchUpload",
4149        );
4150    }
4151
4152    #[test]
4153    fn abort_multipart_nonexistent_upload() {
4154        let svc = make_service();
4155        seed_bucket(&svc, "b");
4156        assert_aws_err(
4157            svc.abort_multipart_upload("123456789012", "b", "key", "bogus"),
4158            "NoSuchUpload",
4159        );
4160    }
4161
4162    #[test]
4163    fn list_parts_nonexistent_upload() {
4164        let svc = make_service();
4165        seed_bucket(&svc, "b");
4166        let req = make_request(Method::GET, "/b/key", &[("uploadId", "bogus")], b"");
4167        assert_aws_err(
4168            svc.list_parts("123456789012", &req, "b", "key", "bogus"),
4169            "NoSuchUpload",
4170        );
4171    }
4172
4173    // ── Error branch tests: config operations ──
4174
4175    #[test]
4176    fn get_bucket_acl_nonexistent() {
4177        let svc = make_service();
4178        let req = make_request(Method::GET, "/nope", &[("acl", "")], b"");
4179        assert_aws_err(
4180            svc.get_bucket_acl("123456789012", &req, "nope"),
4181            "NoSuchBucket",
4182        );
4183    }
4184
4185    #[test]
4186    fn get_bucket_versioning_nonexistent() {
4187        let svc = make_service();
4188        assert_aws_err(
4189            svc.get_bucket_versioning("123456789012", "nope"),
4190            "NoSuchBucket",
4191        );
4192    }
4193
4194    #[test]
4195    fn put_bucket_versioning_nonexistent() {
4196        let svc = make_service();
4197        let req = make_request(
4198            Method::PUT,
4199            "/nope",
4200            &[("versioning", "")],
4201            b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
4202        );
4203        assert_aws_err(
4204            svc.put_bucket_versioning("123456789012", &req, "nope"),
4205            "NoSuchBucket",
4206        );
4207    }
4208
4209    #[test]
4210    fn get_bucket_location_nonexistent() {
4211        let svc = make_service();
4212        assert_aws_err(
4213            svc.get_bucket_location("123456789012", "nope"),
4214            "NoSuchBucket",
4215        );
4216    }
4217
4218    #[test]
4219    fn get_bucket_lifecycle_nonexistent() {
4220        let svc = make_service();
4221        assert_aws_err(
4222            svc.get_bucket_lifecycle("123456789012", "nope"),
4223            "NoSuchBucket",
4224        );
4225    }
4226
4227    #[test]
4228    fn get_bucket_notification_nonexistent() {
4229        let svc = make_service();
4230        assert_aws_err(
4231            svc.get_bucket_notification("123456789012", "nope"),
4232            "NoSuchBucket",
4233        );
4234    }
4235
4236    #[test]
4237    fn get_bucket_encryption_nonexistent() {
4238        let svc = make_service();
4239        assert_aws_err(
4240            svc.get_bucket_encryption("123456789012", "nope"),
4241            "NoSuchBucket",
4242        );
4243    }
4244
4245    #[test]
4246    fn get_bucket_logging_nonexistent() {
4247        let svc = make_service();
4248        assert_aws_err(
4249            svc.get_bucket_logging("123456789012", "nope"),
4250            "NoSuchBucket",
4251        );
4252    }
4253
4254    #[test]
4255    fn get_object_lock_nonexistent() {
4256        let svc = make_service();
4257        assert_aws_err(
4258            svc.get_object_lock_configuration("123456789012", "nope"),
4259            "NoSuchBucket",
4260        );
4261    }
4262
4263    #[test]
4264    fn get_object_attributes_nonexistent_key() {
4265        let svc = make_service();
4266        seed_bucket(&svc, "b");
4267        let req = make_request(Method::GET, "/b/missing", &[("attributes", "")], b"");
4268        assert_aws_err(
4269            svc.get_object_attributes("123456789012", &req, "b", "missing"),
4270            "NoSuchKey",
4271        );
4272    }
4273
4274    #[test]
4275    fn get_object_attributes_nonexistent_bucket() {
4276        let svc = make_service();
4277        let req = make_request(Method::GET, "/nope/key", &[("attributes", "")], b"");
4278        assert_aws_err(
4279            svc.get_object_attributes("123456789012", &req, "nope", "key"),
4280            "NoSuchBucket",
4281        );
4282    }
4283
4284    #[test]
4285    fn restore_object_nonexistent_bucket() {
4286        let svc = make_service();
4287        let req = make_request(
4288            Method::POST,
4289            "/nope/key",
4290            &[("restore", "")],
4291            b"<RestoreRequest><Days>1</Days></RestoreRequest>",
4292        );
4293        assert_aws_err(
4294            svc.restore_object("123456789012", &req, "nope", "key"),
4295            "NoSuchBucket",
4296        );
4297    }
4298
4299    #[test]
4300    fn get_public_access_block_nonexistent() {
4301        let svc = make_service();
4302        assert_aws_err(
4303            svc.get_public_access_block("123456789012", "nope"),
4304            "NoSuchBucket",
4305        );
4306    }
4307
4308    #[test]
4309    fn get_bucket_policy_nonexistent() {
4310        let svc = make_service();
4311        assert_aws_err(
4312            svc.get_bucket_policy("123456789012", "nope"),
4313            "NoSuchBucket",
4314        );
4315    }
4316
4317    #[test]
4318    fn get_bucket_cors_nonexistent() {
4319        let svc = make_service();
4320        assert_aws_err(svc.get_bucket_cors("123456789012", "nope"), "NoSuchBucket");
4321    }
4322
4323    #[test]
4324    fn get_bucket_tagging_nonexistent() {
4325        let svc = make_service();
4326        let req = make_request(Method::GET, "/nope", &[("tagging", "")], b"");
4327        assert_aws_err(
4328            svc.get_bucket_tagging("123456789012", &req, "nope"),
4329            "NoSuchBucket",
4330        );
4331    }
4332
4333    #[test]
4334    fn get_bucket_website_nonexistent() {
4335        let svc = make_service();
4336        assert_aws_err(
4337            svc.get_bucket_website("123456789012", "nope"),
4338            "NoSuchBucket",
4339        );
4340    }
4341
4342    // ── Object lock (lock.rs - 0% coverage) ──
4343
4344    #[test]
4345    fn put_and_get_object_retention() {
4346        let svc = make_service();
4347        seed_bucket(&svc, "lock-b");
4348        seed_object(&svc, "lock-b", "retained.txt", b"data");
4349
4350        let body = b"<Retention><Mode>GOVERNANCE</Mode><RetainUntilDate>2030-01-01T00:00:00Z</RetainUntilDate></Retention>";
4351        let req = make_request(
4352            Method::PUT,
4353            "/lock-b/retained.txt",
4354            &[("retention", "")],
4355            body,
4356        );
4357        svc.put_object_retention("123456789012", &req, "lock-b", "retained.txt")
4358            .unwrap();
4359
4360        let req = make_request(
4361            Method::GET,
4362            "/lock-b/retained.txt",
4363            &[("retention", "")],
4364            b"",
4365        );
4366        let resp = svc
4367            .get_object_retention("123456789012", &req, "lock-b", "retained.txt")
4368            .unwrap();
4369        let body_str = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4370        assert!(body_str.contains("GOVERNANCE"));
4371    }
4372
4373    #[test]
4374    fn get_object_retention_nonexistent_bucket() {
4375        let svc = make_service();
4376        let req = make_request(Method::GET, "/nope/key", &[("retention", "")], b"");
4377        assert_aws_err(
4378            svc.get_object_retention("123456789012", &req, "nope", "key"),
4379            "NoSuchBucket",
4380        );
4381    }
4382
4383    #[test]
4384    fn get_object_retention_nonexistent_key() {
4385        let svc = make_service();
4386        seed_bucket(&svc, "lock-b2");
4387        let req = make_request(Method::GET, "/lock-b2/missing", &[("retention", "")], b"");
4388        assert_aws_err(
4389            svc.get_object_retention("123456789012", &req, "lock-b2", "missing"),
4390            "NoSuchKey",
4391        );
4392    }
4393
4394    #[test]
4395    fn put_and_get_object_legal_hold() {
4396        let svc = make_service();
4397        seed_bucket(&svc, "hold-b");
4398        seed_object(&svc, "hold-b", "held.txt", b"data");
4399
4400        let body = b"<LegalHold><Status>ON</Status></LegalHold>";
4401        let req = make_request(Method::PUT, "/hold-b/held.txt", &[("legal-hold", "")], body);
4402        svc.put_object_legal_hold("123456789012", &req, "hold-b", "held.txt")
4403            .unwrap();
4404
4405        let req = make_request(Method::GET, "/hold-b/held.txt", &[("legal-hold", "")], b"");
4406        let resp = svc
4407            .get_object_legal_hold("123456789012", &req, "hold-b", "held.txt")
4408            .unwrap();
4409        let body_str = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4410        assert!(body_str.contains("ON"));
4411    }
4412
4413    #[test]
4414    fn get_object_legal_hold_nonexistent() {
4415        let svc = make_service();
4416        let req = make_request(Method::GET, "/nope/key", &[("legal-hold", "")], b"");
4417        assert_aws_err(
4418            svc.get_object_legal_hold("123456789012", &req, "nope", "key"),
4419            "NoSuchBucket",
4420        );
4421    }
4422
4423    // ── Object ACL (acl.rs - 0% coverage) ──
4424
4425    #[test]
4426    fn get_object_acl_default() {
4427        let svc = make_service();
4428        seed_bucket(&svc, "acl-b");
4429        seed_object(&svc, "acl-b", "file.txt", b"data");
4430
4431        let req = make_request(Method::GET, "/acl-b/file.txt", &[("acl", "")], b"");
4432        let resp = svc
4433            .get_object_acl("123456789012", &req, "acl-b", "file.txt")
4434            .unwrap();
4435        let body_str = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4436        assert!(body_str.contains("AccessControlPolicy"));
4437    }
4438
4439    #[test]
4440    fn get_object_acl_nonexistent_bucket() {
4441        let svc = make_service();
4442        let req = make_request(Method::GET, "/nope/key", &[("acl", "")], b"");
4443        assert_aws_err(
4444            svc.get_object_acl("123456789012", &req, "nope", "key"),
4445            "NoSuchBucket",
4446        );
4447    }
4448
4449    #[test]
4450    fn get_object_acl_nonexistent_key() {
4451        let svc = make_service();
4452        seed_bucket(&svc, "acl-b2");
4453        let req = make_request(Method::GET, "/acl-b2/missing", &[("acl", "")], b"");
4454        assert_aws_err(
4455            svc.get_object_acl("123456789012", &req, "acl-b2", "missing"),
4456            "NoSuchKey",
4457        );
4458    }
4459
4460    #[test]
4461    fn put_object_acl() {
4462        let svc = make_service();
4463        seed_bucket(&svc, "acl-put-b");
4464        seed_object(&svc, "acl-put-b", "file.txt", b"data");
4465
4466        let acl_xml = b"<AccessControlPolicy><Owner><ID>owner</ID></Owner><AccessControlList><Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\"><ID>owner</ID></Grantee><Permission>FULL_CONTROL</Permission></Grant></AccessControlList></AccessControlPolicy>";
4467        let req = make_request(Method::PUT, "/acl-put-b/file.txt", &[("acl", "")], acl_xml);
4468        svc.put_object_acl("123456789012", &req, "acl-put-b", "file.txt")
4469            .unwrap();
4470    }
4471
4472    // ── Happy-path handler tests (objects.rs coverage) ──
4473
4474    #[tokio::test]
4475    async fn put_object_via_handler_and_get_back() {
4476        let svc = make_service();
4477        seed_bucket(&svc, "hp");
4478
4479        // PUT through handler (not seed_object)
4480        let req = make_request(Method::PUT, "/hp/test.txt", &[], b"hello world");
4481        let resp = svc
4482            .put_object("123456789012", &req, "hp", "test.txt")
4483            .await
4484            .unwrap();
4485        assert_eq!(resp.status, StatusCode::OK);
4486
4487        // GET back
4488        let req = make_request(Method::GET, "/hp/test.txt", &[], b"");
4489        let resp = svc
4490            .get_object("123456789012", &req, "hp", "test.txt")
4491            .unwrap();
4492        assert_eq!(resp.body.expect_bytes(), b"hello world");
4493    }
4494
4495    #[tokio::test]
4496    async fn put_object_with_content_type() {
4497        let svc = make_service();
4498        seed_bucket(&svc, "ct");
4499
4500        let mut req = make_request(Method::PUT, "/ct/doc.json", &[], b"{\"key\":\"val\"}");
4501        req.headers
4502            .insert("content-type", "application/json".parse().unwrap());
4503        svc.put_object("123456789012", &req, "ct", "doc.json")
4504            .await
4505            .unwrap();
4506
4507        let req = make_request(Method::GET, "/ct/doc.json", &[], b"");
4508        let resp = svc
4509            .get_object("123456789012", &req, "ct", "doc.json")
4510            .unwrap();
4511        assert_eq!(resp.content_type, "application/json");
4512    }
4513
4514    #[tokio::test]
4515    async fn put_object_with_metadata() {
4516        let svc = make_service();
4517        seed_bucket(&svc, "meta");
4518
4519        let mut req = make_request(Method::PUT, "/meta/obj", &[], b"data");
4520        req.headers
4521            .insert("x-amz-meta-color", "blue".parse().unwrap());
4522        req.headers
4523            .insert("x-amz-meta-size", "large".parse().unwrap());
4524        svc.put_object("123456789012", &req, "meta", "obj")
4525            .await
4526            .unwrap();
4527
4528        let req = make_request(Method::HEAD, "/meta/obj", &[], b"");
4529        let resp = svc
4530            .head_object("123456789012", &req, "meta", "obj")
4531            .unwrap();
4532        assert!(resp
4533            .headers
4534            .get("x-amz-meta-color")
4535            .is_some_and(|v| v == "blue"));
4536    }
4537
4538    #[tokio::test]
4539    async fn put_object_returns_etag() {
4540        let svc = make_service();
4541        seed_bucket(&svc, "etag");
4542
4543        let req = make_request(Method::PUT, "/etag/f.txt", &[], b"content");
4544        let resp = svc
4545            .put_object("123456789012", &req, "etag", "f.txt")
4546            .await
4547            .unwrap();
4548        assert!(resp.headers.get("etag").is_some());
4549    }
4550
4551    #[tokio::test]
4552    async fn head_object_returns_headers() {
4553        let svc = make_service();
4554        seed_bucket(&svc, "head");
4555
4556        let req = make_request(Method::PUT, "/head/f.txt", &[], b"12345");
4557        svc.put_object("123456789012", &req, "head", "f.txt")
4558            .await
4559            .unwrap();
4560
4561        let req = make_request(Method::HEAD, "/head/f.txt", &[], b"");
4562        let resp = svc
4563            .head_object("123456789012", &req, "head", "f.txt")
4564            .unwrap();
4565        assert_eq!(
4566            resp.headers
4567                .get("content-length")
4568                .unwrap()
4569                .to_str()
4570                .unwrap(),
4571            "5"
4572        );
4573    }
4574
4575    #[tokio::test]
4576    async fn delete_object_via_handler() {
4577        let svc = make_service();
4578        seed_bucket(&svc, "del");
4579
4580        let req = make_request(Method::PUT, "/del/rm.txt", &[], b"bye");
4581        svc.put_object("123456789012", &req, "del", "rm.txt")
4582            .await
4583            .unwrap();
4584
4585        let req = make_request(Method::DELETE, "/del/rm.txt", &[], b"");
4586        svc.delete_object("123456789012", &req, "del", "rm.txt")
4587            .unwrap();
4588
4589        let req = make_request(Method::GET, "/del/rm.txt", &[], b"");
4590        assert_aws_err(
4591            svc.get_object("123456789012", &req, "del", "rm.txt"),
4592            "NoSuchKey",
4593        );
4594    }
4595
4596    #[tokio::test]
4597    async fn copy_object_via_handler() {
4598        let svc = make_service();
4599        seed_bucket(&svc, "cpsrc");
4600        seed_bucket(&svc, "cpdst");
4601
4602        let req = make_request(Method::PUT, "/cpsrc/orig.txt", &[], b"original");
4603        svc.put_object("123456789012", &req, "cpsrc", "orig.txt")
4604            .await
4605            .unwrap();
4606
4607        let mut req = make_request(Method::PUT, "/cpdst/copy.txt", &[], b"");
4608        req.headers
4609            .insert("x-amz-copy-source", "cpsrc/orig.txt".parse().unwrap());
4610        svc.copy_object("123456789012", &req, "cpdst", "copy.txt")
4611            .unwrap();
4612
4613        let req = make_request(Method::GET, "/cpdst/copy.txt", &[], b"");
4614        let resp = svc
4615            .get_object("123456789012", &req, "cpdst", "copy.txt")
4616            .unwrap();
4617        assert_eq!(resp.body.expect_bytes(), b"original");
4618    }
4619
4620    #[tokio::test]
4621    async fn copy_object_within_same_bucket() {
4622        let svc = make_service();
4623        seed_bucket(&svc, "same");
4624
4625        let req = make_request(Method::PUT, "/same/a.txt", &[], b"aaa");
4626        svc.put_object("123456789012", &req, "same", "a.txt")
4627            .await
4628            .unwrap();
4629
4630        let mut req = make_request(Method::PUT, "/same/b.txt", &[], b"");
4631        req.headers
4632            .insert("x-amz-copy-source", "same/a.txt".parse().unwrap());
4633        svc.copy_object("123456789012", &req, "same", "b.txt")
4634            .unwrap();
4635
4636        let req = make_request(Method::GET, "/same/b.txt", &[], b"");
4637        let resp = svc
4638            .get_object("123456789012", &req, "same", "b.txt")
4639            .unwrap();
4640        assert_eq!(resp.body.expect_bytes(), b"aaa");
4641    }
4642
4643    #[tokio::test]
4644    async fn list_objects_v2_via_handler() {
4645        let svc = make_service();
4646        seed_bucket(&svc, "lsv2");
4647
4648        for i in 0..3 {
4649            let key = format!("file{i}.txt");
4650            let req = make_request(Method::PUT, &format!("/lsv2/{key}"), &[], b"data");
4651            svc.put_object("123456789012", &req, "lsv2", &key)
4652                .await
4653                .unwrap();
4654        }
4655
4656        let req = make_request(Method::GET, "/lsv2", &[("list-type", "2")], b"");
4657        let resp = svc.list_objects_v2("123456789012", &req, "lsv2").unwrap();
4658        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4659        assert!(body.contains("<KeyCount>3</KeyCount>"));
4660    }
4661
4662    #[tokio::test]
4663    async fn list_objects_v2_with_prefix() {
4664        let svc = make_service();
4665        seed_bucket(&svc, "pfx");
4666
4667        for key in &["docs/a.txt", "docs/b.txt", "images/c.png"] {
4668            let req = make_request(Method::PUT, &format!("/pfx/{key}"), &[], b"x");
4669            svc.put_object("123456789012", &req, "pfx", key)
4670                .await
4671                .unwrap();
4672        }
4673
4674        let req = make_request(
4675            Method::GET,
4676            "/pfx",
4677            &[("list-type", "2"), ("prefix", "docs/")],
4678            b"",
4679        );
4680        let resp = svc.list_objects_v2("123456789012", &req, "pfx").unwrap();
4681        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4682        assert!(body.contains("<KeyCount>2</KeyCount>"));
4683    }
4684
4685    #[tokio::test]
4686    async fn list_objects_v2_with_delimiter() {
4687        let svc = make_service();
4688        seed_bucket(&svc, "dlm");
4689
4690        for key in &["a/1.txt", "a/2.txt", "b/3.txt", "root.txt"] {
4691            let req = make_request(Method::PUT, &format!("/dlm/{key}"), &[], b"x");
4692            svc.put_object("123456789012", &req, "dlm", key)
4693                .await
4694                .unwrap();
4695        }
4696
4697        let req = make_request(
4698            Method::GET,
4699            "/dlm",
4700            &[("list-type", "2"), ("delimiter", "/")],
4701            b"",
4702        );
4703        let resp = svc.list_objects_v2("123456789012", &req, "dlm").unwrap();
4704        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4705        // Should have common prefixes a/ and b/
4706        assert!(body.contains("<CommonPrefixes>"));
4707        // root.txt should be in contents
4708        assert!(body.contains("root.txt"));
4709    }
4710
4711    #[tokio::test]
4712    async fn list_objects_v1_via_handler() {
4713        let svc = make_service();
4714        seed_bucket(&svc, "lsv1");
4715
4716        let req = make_request(Method::PUT, "/lsv1/test.txt", &[], b"data");
4717        svc.put_object("123456789012", &req, "lsv1", "test.txt")
4718            .await
4719            .unwrap();
4720
4721        let req = make_request(Method::GET, "/lsv1", &[], b"");
4722        let resp = svc.list_objects_v1("123456789012", &req, "lsv1").unwrap();
4723        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4724        assert!(body.contains("<Key>test.txt</Key>"));
4725    }
4726
4727    #[tokio::test]
4728    async fn delete_objects_batch() {
4729        let svc = make_service();
4730        seed_bucket(&svc, "bdel");
4731
4732        for i in 0..3 {
4733            let key = format!("d{i}.txt");
4734            let req = make_request(Method::PUT, &format!("/bdel/{key}"), &[], b"x");
4735            svc.put_object("123456789012", &req, "bdel", &key)
4736                .await
4737                .unwrap();
4738        }
4739
4740        let delete_xml = b"<Delete><Object><Key>d0.txt</Key></Object><Object><Key>d1.txt</Key></Object></Delete>";
4741        let req = make_request(Method::POST, "/bdel", &[("delete", "")], delete_xml);
4742        let resp = svc.delete_objects("123456789012", &req, "bdel").unwrap();
4743        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4744        assert!(body.contains("<Deleted>"));
4745
4746        // d2.txt should still exist
4747        let req = make_request(Method::GET, "/bdel/d2.txt", &[], b"");
4748        svc.get_object("123456789012", &req, "bdel", "d2.txt")
4749            .unwrap();
4750    }
4751
4752    #[tokio::test]
4753    async fn put_object_overwrites_existing() {
4754        let svc = make_service();
4755        seed_bucket(&svc, "ow");
4756
4757        let req = make_request(Method::PUT, "/ow/f.txt", &[], b"version1");
4758        svc.put_object("123456789012", &req, "ow", "f.txt")
4759            .await
4760            .unwrap();
4761
4762        let req = make_request(Method::PUT, "/ow/f.txt", &[], b"version2");
4763        svc.put_object("123456789012", &req, "ow", "f.txt")
4764            .await
4765            .unwrap();
4766
4767        let req = make_request(Method::GET, "/ow/f.txt", &[], b"");
4768        let resp = svc.get_object("123456789012", &req, "ow", "f.txt").unwrap();
4769        assert_eq!(resp.body.expect_bytes(), b"version2");
4770    }
4771
4772    #[tokio::test]
4773    async fn get_object_attributes_via_handler() {
4774        let svc = make_service();
4775        seed_bucket(&svc, "attr");
4776
4777        let req = make_request(Method::PUT, "/attr/f.txt", &[], b"content");
4778        svc.put_object("123456789012", &req, "attr", "f.txt")
4779            .await
4780            .unwrap();
4781
4782        let mut req = make_request(Method::GET, "/attr/f.txt", &[], b"");
4783        req.headers.insert(
4784            "x-amz-object-attributes",
4785            "ETag,ObjectSize".parse().unwrap(),
4786        );
4787        let resp = svc
4788            .get_object_attributes("123456789012", &req, "attr", "f.txt")
4789            .unwrap();
4790        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4791        assert!(body.contains("<ObjectSize>"));
4792    }
4793
4794    // ── Multipart upload happy path ──
4795
4796    #[tokio::test]
4797    async fn multipart_upload_lifecycle() {
4798        let svc = make_service();
4799        seed_bucket(&svc, "mp");
4800
4801        // Create
4802        let req = make_request(Method::POST, "/mp/big.bin", &[("uploads", "")], b"");
4803        let resp = svc
4804            .create_multipart_upload("123456789012", &req, "mp", "big.bin")
4805            .unwrap();
4806        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4807        let uid_start = body.find("<UploadId>").unwrap() + 10;
4808        let uid_end = body.find("</UploadId>").unwrap();
4809        let upload_id = &body[uid_start..uid_end];
4810
4811        // Upload part 1 (>5MB to pass minimum size check)
4812        let big_data = vec![b'A'; 5 * 1024 * 1024 + 1];
4813        let req = make_request(Method::PUT, "/mp/big.bin", &[], &big_data);
4814        let resp = svc
4815            .upload_part("123456789012", &req, "mp", "big.bin", upload_id, 1)
4816            .await
4817            .unwrap();
4818        let etag1 = resp
4819            .headers
4820            .get("etag")
4821            .unwrap()
4822            .to_str()
4823            .unwrap()
4824            .to_string();
4825
4826        // Upload part 2 (last part can be any size)
4827        let req = make_request(Method::PUT, "/mp/big.bin", &[], b"part2-data");
4828        let resp = svc
4829            .upload_part("123456789012", &req, "mp", "big.bin", upload_id, 2)
4830            .await
4831            .unwrap();
4832        let etag2 = resp
4833            .headers
4834            .get("etag")
4835            .unwrap()
4836            .to_str()
4837            .unwrap()
4838            .to_string();
4839
4840        // List parts
4841        let req = make_request(Method::GET, "/mp/big.bin", &[], b"");
4842        let resp = svc
4843            .list_parts("123456789012", &req, "mp", "big.bin", upload_id)
4844            .unwrap();
4845        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4846        assert!(body.contains("<Part>"));
4847
4848        // Complete
4849        let complete_xml = format!(
4850            "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>{etag1}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>{etag2}</ETag></Part></CompleteMultipartUpload>"
4851        );
4852        let req = make_request(Method::POST, "/mp/big.bin", &[], complete_xml.as_bytes());
4853        svc.complete_multipart_upload("123456789012", &req, "mp", "big.bin", upload_id)
4854            .unwrap();
4855
4856        // Verify object exists
4857        let req = make_request(Method::GET, "/mp/big.bin", &[], b"");
4858        let resp = svc
4859            .get_object("123456789012", &req, "mp", "big.bin")
4860            .unwrap();
4861        let body = resp.body.expect_bytes();
4862        // First part is 5MB+1 of 'A', second is "part2-data"
4863        assert!(body.len() > 5 * 1024 * 1024);
4864    }
4865
4866    #[test]
4867    fn multipart_upload_abort() {
4868        let svc = make_service();
4869        seed_bucket(&svc, "mpa");
4870
4871        let req = make_request(Method::POST, "/mpa/abort.bin", &[("uploads", "")], b"");
4872        let resp = svc
4873            .create_multipart_upload("123456789012", &req, "mpa", "abort.bin")
4874            .unwrap();
4875        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4876        let uid_start = body.find("<UploadId>").unwrap() + 10;
4877        let uid_end = body.find("</UploadId>").unwrap();
4878        let upload_id = body[uid_start..uid_end].to_string();
4879
4880        svc.abort_multipart_upload("123456789012", "mpa", "abort.bin", &upload_id)
4881            .unwrap();
4882
4883        // Upload should be gone
4884        let req = make_request(Method::GET, "/mpa/abort.bin", &[], b"");
4885        assert_aws_err(
4886            svc.list_parts("123456789012", &req, "mpa", "abort.bin", &upload_id),
4887            "NoSuchUpload",
4888        );
4889    }
4890
4891    #[test]
4892    fn list_multipart_uploads() {
4893        let svc = make_service();
4894        seed_bucket(&svc, "mpl");
4895
4896        let req = make_request(Method::POST, "/mpl/f1.bin", &[("uploads", "")], b"");
4897        svc.create_multipart_upload("123456789012", &req, "mpl", "f1.bin")
4898            .unwrap();
4899
4900        let resp = svc.list_multipart_uploads("123456789012", "mpl").unwrap();
4901        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4902        assert!(body.contains("<Upload>"));
4903        assert!(body.contains("f1.bin"));
4904    }
4905
4906    // ── Config handler happy paths ──
4907
4908    #[test]
4909    fn put_and_get_bucket_versioning() {
4910        let svc = make_service();
4911        seed_bucket(&svc, "ver");
4912
4913        let req = make_request(
4914            Method::PUT,
4915            "/ver",
4916            &[("versioning", "")],
4917            b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
4918        );
4919        svc.put_bucket_versioning("123456789012", &req, "ver")
4920            .unwrap();
4921
4922        let resp = svc.get_bucket_versioning("123456789012", "ver").unwrap();
4923        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4924        assert!(body.contains("Enabled"));
4925    }
4926
4927    #[test]
4928    fn put_and_get_bucket_lifecycle() {
4929        let svc = make_service();
4930        seed_bucket(&svc, "lc");
4931
4932        let xml = b"<LifecycleConfiguration><Rule><ID>expire</ID><Filter><Prefix></Prefix></Filter><Status>Enabled</Status><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>";
4933        let req = make_request(Method::PUT, "/lc", &[("lifecycle", "")], xml);
4934        svc.put_bucket_lifecycle("123456789012", &req, "lc")
4935            .unwrap();
4936
4937        let resp = svc.get_bucket_lifecycle("123456789012", "lc").unwrap();
4938        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4939        assert!(body.contains("<Rule>"));
4940    }
4941
4942    #[test]
4943    fn put_and_get_bucket_notification() {
4944        let svc = make_service();
4945        seed_bucket(&svc, "notif");
4946
4947        let xml = b"<NotificationConfiguration></NotificationConfiguration>";
4948        let req = make_request(Method::PUT, "/notif", &[("notification", "")], xml);
4949        svc.put_bucket_notification("123456789012", &req, "notif")
4950            .unwrap();
4951
4952        let resp = svc
4953            .get_bucket_notification("123456789012", "notif")
4954            .unwrap();
4955        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4956        assert!(body.contains("NotificationConfiguration"));
4957    }
4958
4959    #[test]
4960    fn put_and_get_and_delete_bucket_encryption() {
4961        let svc = make_service();
4962        seed_bucket(&svc, "enc");
4963
4964        let xml = b"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>";
4965        let req = make_request(Method::PUT, "/enc", &[("encryption", "")], xml);
4966        svc.put_bucket_encryption("123456789012", &req, "enc")
4967            .unwrap();
4968
4969        let resp = svc.get_bucket_encryption("123456789012", "enc").unwrap();
4970        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4971        assert!(body.contains("AES256"));
4972
4973        svc.delete_bucket_encryption("123456789012", "enc").unwrap();
4974    }
4975
4976    #[test]
4977    fn bucket_logging_put_and_get() {
4978        let svc = make_service();
4979        seed_bucket(&svc, "logging-b");
4980
4981        let xml = b"<BucketLoggingStatus><LoggingEnabled><TargetBucket>logging-b</TargetBucket><TargetPrefix>logs/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>";
4982        let req = make_request(Method::PUT, "/logging-b", &[("logging", "")], xml);
4983        svc.put_bucket_logging("123456789012", &req, "logging-b")
4984            .unwrap();
4985
4986        let resp = svc.get_bucket_logging("123456789012", "logging-b").unwrap();
4987        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
4988        assert!(body.contains("LoggingEnabled"));
4989    }
4990
4991    // ── Versioned object operations ──
4992
4993    #[tokio::test]
4994    async fn versioned_put_and_get() {
4995        let svc = make_service();
4996        seed_bucket(&svc, "vb");
4997
4998        // Enable versioning
4999        let req = make_request(
5000            Method::PUT,
5001            "/vb",
5002            &[("versioning", "")],
5003            b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
5004        );
5005        svc.put_bucket_versioning("123456789012", &req, "vb")
5006            .unwrap();
5007
5008        // Put v1
5009        let req = make_request(Method::PUT, "/vb/key", &[], b"version1");
5010        let resp = svc
5011            .put_object("123456789012", &req, "vb", "key")
5012            .await
5013            .unwrap();
5014        let v1 = resp
5015            .headers
5016            .get("x-amz-version-id")
5017            .map(|h| h.to_str().unwrap().to_string());
5018        assert!(v1.is_some());
5019
5020        // Put v2
5021        let req = make_request(Method::PUT, "/vb/key", &[], b"version2");
5022        let resp = svc
5023            .put_object("123456789012", &req, "vb", "key")
5024            .await
5025            .unwrap();
5026        let _v2 = resp
5027            .headers
5028            .get("x-amz-version-id")
5029            .map(|h| h.to_str().unwrap().to_string());
5030
5031        // Get latest
5032        let req = make_request(Method::GET, "/vb/key", &[], b"");
5033        let resp = svc.get_object("123456789012", &req, "vb", "key").unwrap();
5034        assert_eq!(resp.body.expect_bytes(), b"version2");
5035
5036        // List versions
5037        let req = make_request(Method::GET, "/vb", &[("versions", "")], b"");
5038        let resp = svc
5039            .list_object_versions("123456789012", &req, "vb")
5040            .unwrap();
5041        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5042        assert!(body.contains("<Version>"));
5043    }
5044
5045    // ── Conditional GET ──
5046
5047    #[tokio::test]
5048    async fn get_object_if_match_succeeds() {
5049        let svc = make_service();
5050        seed_bucket(&svc, "cond");
5051
5052        let req = make_request(Method::PUT, "/cond/f.txt", &[], b"data");
5053        let resp = svc
5054            .put_object("123456789012", &req, "cond", "f.txt")
5055            .await
5056            .unwrap();
5057        let etag = resp
5058            .headers
5059            .get("etag")
5060            .unwrap()
5061            .to_str()
5062            .unwrap()
5063            .to_string();
5064
5065        let mut req = make_request(Method::GET, "/cond/f.txt", &[], b"");
5066        req.headers.insert("if-match", etag.parse().unwrap());
5067        let resp = svc
5068            .get_object("123456789012", &req, "cond", "f.txt")
5069            .unwrap();
5070        assert_eq!(resp.body.expect_bytes(), b"data");
5071    }
5072
5073    #[tokio::test]
5074    async fn get_object_if_match_fails() {
5075        let svc = make_service();
5076        seed_bucket(&svc, "cond2");
5077
5078        let req = make_request(Method::PUT, "/cond2/f.txt", &[], b"data");
5079        svc.put_object("123456789012", &req, "cond2", "f.txt")
5080            .await
5081            .unwrap();
5082
5083        let mut req = make_request(Method::GET, "/cond2/f.txt", &[], b"");
5084        req.headers
5085            .insert("if-match", "\"wrong-etag\"".parse().unwrap());
5086        assert_aws_err(
5087            svc.get_object("123456789012", &req, "cond2", "f.txt"),
5088            "PreconditionFailed",
5089        );
5090    }
5091
5092    #[tokio::test]
5093    async fn get_object_if_none_match_returns_304() {
5094        let svc = make_service();
5095        seed_bucket(&svc, "cond3");
5096
5097        let req = make_request(Method::PUT, "/cond3/f.txt", &[], b"data");
5098        let resp = svc
5099            .put_object("123456789012", &req, "cond3", "f.txt")
5100            .await
5101            .unwrap();
5102        let etag = resp
5103            .headers
5104            .get("etag")
5105            .unwrap()
5106            .to_str()
5107            .unwrap()
5108            .to_string();
5109
5110        let mut req = make_request(Method::GET, "/cond3/f.txt", &[], b"");
5111        req.headers.insert("if-none-match", etag.parse().unwrap());
5112        let err = svc.get_object("123456789012", &req, "cond3", "f.txt");
5113        // Should return PreconditionFailed or 304 Not Modified
5114        assert!(err.is_err());
5115    }
5116
5117    // ── Put with if-none-match (conditional put) ──
5118
5119    #[tokio::test]
5120    async fn put_object_if_none_match_prevents_overwrite() {
5121        let svc = make_service();
5122        seed_bucket(&svc, "cnm");
5123
5124        let req = make_request(Method::PUT, "/cnm/f.txt", &[], b"first");
5125        svc.put_object("123456789012", &req, "cnm", "f.txt")
5126            .await
5127            .unwrap();
5128
5129        // Try to put again with if-none-match: *
5130        let mut req = make_request(Method::PUT, "/cnm/f.txt", &[], b"second");
5131        req.headers.insert("if-none-match", "*".parse().unwrap());
5132        assert_aws_err(
5133            svc.put_object("123456789012", &req, "cnm", "f.txt").await,
5134            "PreconditionFailed",
5135        );
5136    }
5137
5138    // ── Storage class ──
5139
5140    #[tokio::test]
5141    async fn put_object_with_storage_class() {
5142        let svc = make_service();
5143        seed_bucket(&svc, "sc");
5144
5145        let mut req = make_request(Method::PUT, "/sc/f.txt", &[], b"data");
5146        req.headers
5147            .insert("x-amz-storage-class", "GLACIER".parse().unwrap());
5148        svc.put_object("123456789012", &req, "sc", "f.txt")
5149            .await
5150            .unwrap();
5151
5152        let req = make_request(Method::HEAD, "/sc/f.txt", &[], b"");
5153        let resp = svc
5154            .head_object("123456789012", &req, "sc", "f.txt")
5155            .unwrap();
5156        assert_eq!(
5157            resp.headers
5158                .get("x-amz-storage-class")
5159                .unwrap()
5160                .to_str()
5161                .unwrap(),
5162            "GLACIER"
5163        );
5164    }
5165
5166    // ── Delete versioned object creates delete marker ──
5167
5168    #[tokio::test]
5169    async fn delete_versioned_object_creates_marker() {
5170        let svc = make_service();
5171        seed_bucket(&svc, "dv");
5172
5173        let req = make_request(
5174            Method::PUT,
5175            "/dv",
5176            &[("versioning", "")],
5177            b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
5178        );
5179        svc.put_bucket_versioning("123456789012", &req, "dv")
5180            .unwrap();
5181
5182        let req = make_request(Method::PUT, "/dv/key", &[], b"data");
5183        svc.put_object("123456789012", &req, "dv", "key")
5184            .await
5185            .unwrap();
5186
5187        let req = make_request(Method::DELETE, "/dv/key", &[], b"");
5188        let resp = svc
5189            .delete_object("123456789012", &req, "dv", "key")
5190            .unwrap();
5191        assert!(resp.headers.get("x-amz-delete-marker").is_some());
5192
5193        // GET should fail (object is "deleted")
5194        let req = make_request(Method::GET, "/dv/key", &[], b"");
5195        assert_aws_err(
5196            svc.get_object("123456789012", &req, "dv", "key"),
5197            "NoSuchKey",
5198        );
5199    }
5200
5201    // ── Copy with metadata replacement ──
5202
5203    #[tokio::test]
5204    async fn copy_object_with_metadata_replace() {
5205        let svc = make_service();
5206        seed_bucket(&svc, "cpm");
5207
5208        let mut req = make_request(Method::PUT, "/cpm/src", &[], b"data");
5209        req.headers
5210            .insert("x-amz-meta-original", "yes".parse().unwrap());
5211        svc.put_object("123456789012", &req, "cpm", "src")
5212            .await
5213            .unwrap();
5214
5215        let mut req = make_request(Method::PUT, "/cpm/dst", &[], b"");
5216        req.headers
5217            .insert("x-amz-copy-source", "cpm/src".parse().unwrap());
5218        req.headers
5219            .insert("x-amz-metadata-directive", "REPLACE".parse().unwrap());
5220        req.headers
5221            .insert("x-amz-meta-new-key", "new-val".parse().unwrap());
5222        svc.copy_object("123456789012", &req, "cpm", "dst").unwrap();
5223
5224        let req = make_request(Method::HEAD, "/cpm/dst", &[], b"");
5225        let resp = svc.head_object("123456789012", &req, "cpm", "dst").unwrap();
5226        assert!(resp
5227            .headers
5228            .get("x-amz-meta-new-key")
5229            .is_some_and(|v| v == "new-val"));
5230        // Original metadata should NOT be present
5231        assert!(resp.headers.get("x-amz-meta-original").is_none());
5232    }
5233
5234    // ── Large list with pagination (max-keys) ──
5235
5236    #[tokio::test]
5237    async fn list_objects_v2_with_max_keys() {
5238        let svc = make_service();
5239        seed_bucket(&svc, "pg");
5240
5241        for i in 0..5 {
5242            let key = format!("k{i}");
5243            let req = make_request(Method::PUT, &format!("/pg/{key}"), &[], b"x");
5244            svc.put_object("123456789012", &req, "pg", &key)
5245                .await
5246                .unwrap();
5247        }
5248
5249        let req = make_request(
5250            Method::GET,
5251            "/pg",
5252            &[("list-type", "2"), ("max-keys", "2")],
5253            b"",
5254        );
5255        let resp = svc.list_objects_v2("123456789012", &req, "pg").unwrap();
5256        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5257        assert!(body.contains("<IsTruncated>true</IsTruncated>"));
5258        assert!(body.contains("<MaxKeys>2</MaxKeys>"));
5259    }
5260
5261    // ── buckets.rs coverage (list/create/delete/head/location) ──
5262
5263    #[test]
5264    fn list_buckets_empty_account() {
5265        let svc = make_service();
5266        let req = make_request(Method::GET, "/", &[], b"");
5267        let resp = svc.list_buckets("123456789012", &req).unwrap();
5268        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5269        assert!(body.contains("<ListAllMyBucketsResult"));
5270        assert!(body.contains("<Owner><ID>123456789012</ID>"));
5271        assert!(body.contains("<Buckets></Buckets>"));
5272    }
5273
5274    #[test]
5275    fn list_buckets_sorted_by_name() {
5276        let svc = make_service();
5277        seed_bucket(&svc, "zeta");
5278        seed_bucket(&svc, "alpha");
5279        seed_bucket(&svc, "middle");
5280
5281        let req = make_request(Method::GET, "/", &[], b"");
5282        let resp = svc.list_buckets("123456789012", &req).unwrap();
5283        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5284        let a = body.find("alpha").unwrap();
5285        let m = body.find("middle").unwrap();
5286        let z = body.find("zeta").unwrap();
5287        assert!(a < m && m < z, "buckets must be sorted");
5288    }
5289
5290    fn seed_bucket_in_region(svc: &S3Service, name: &str, region: &str) {
5291        let mut mas = svc.state.write();
5292        let state = mas.default_mut();
5293        state
5294            .buckets
5295            .insert(name.to_string(), S3Bucket::new(name, region, "owner"));
5296    }
5297
5298    #[test]
5299    fn list_buckets_includes_bucket_region() {
5300        let svc = make_service();
5301        seed_bucket(&svc, "alpha");
5302
5303        let req = make_request(Method::GET, "/", &[], b"");
5304        let resp = svc.list_buckets("123456789012", &req).unwrap();
5305        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5306        assert!(
5307            body.contains("<BucketRegion>us-east-1</BucketRegion>"),
5308            "response should include BucketRegion per-bucket: {body}"
5309        );
5310    }
5311
5312    #[test]
5313    fn list_buckets_filter_by_bucket_region() {
5314        let svc = make_service();
5315        seed_bucket_in_region(&svc, "east-bucket", "us-east-1");
5316        seed_bucket_in_region(&svc, "west-bucket", "us-west-2");
5317
5318        let req = make_request(Method::GET, "/", &[("bucket-region", "us-west-2")], b"");
5319        let resp = svc.list_buckets("123456789012", &req).unwrap();
5320        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5321        assert!(body.contains("west-bucket"));
5322        assert!(!body.contains("east-bucket"));
5323        assert!(body.contains("<BucketRegion>us-west-2</BucketRegion>"));
5324    }
5325
5326    #[test]
5327    fn list_buckets_filter_by_prefix() {
5328        let svc = make_service();
5329        seed_bucket(&svc, "foo-1");
5330        seed_bucket(&svc, "foo-2");
5331        seed_bucket(&svc, "bar");
5332
5333        let req = make_request(Method::GET, "/", &[("prefix", "foo-")], b"");
5334        let resp = svc.list_buckets("123456789012", &req).unwrap();
5335        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5336        assert!(body.contains("foo-1"));
5337        assert!(body.contains("foo-2"));
5338        assert!(!body.contains("<Name>bar</Name>"));
5339        assert!(body.contains("<Prefix>foo-</Prefix>"));
5340    }
5341
5342    #[test]
5343    fn list_buckets_max_buckets_paginates() {
5344        let svc = make_service();
5345        for n in &["a", "b", "c", "d", "e"] {
5346            seed_bucket(&svc, n);
5347        }
5348
5349        let req = make_request(Method::GET, "/", &[("max-buckets", "2")], b"");
5350        let resp = svc.list_buckets("123456789012", &req).unwrap();
5351        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5352        assert!(body.contains("<Name>a</Name>"));
5353        assert!(body.contains("<Name>b</Name>"));
5354        assert!(!body.contains("<Name>c</Name>"));
5355        assert!(body.contains("<ContinuationToken>"));
5356    }
5357
5358    #[test]
5359    fn list_buckets_continuation_token_resumes() {
5360        let svc = make_service();
5361        for n in &["a", "b", "c", "d", "e"] {
5362            seed_bucket(&svc, n);
5363        }
5364
5365        let req = make_request(Method::GET, "/", &[("max-buckets", "2")], b"");
5366        let resp = svc.list_buckets("123456789012", &req).unwrap();
5367        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5368        let start = body.find("<ContinuationToken>").unwrap() + "<ContinuationToken>".len();
5369        let end = body.find("</ContinuationToken>").unwrap();
5370        let token = body[start..end].to_string();
5371
5372        let req2 = make_request(
5373            Method::GET,
5374            "/",
5375            &[("max-buckets", "2"), ("continuation-token", &token)],
5376            b"",
5377        );
5378        let resp2 = svc.list_buckets("123456789012", &req2).unwrap();
5379        let body2 = std::str::from_utf8(resp2.body.expect_bytes()).unwrap();
5380        assert!(body2.contains("<Name>c</Name>"));
5381        assert!(body2.contains("<Name>d</Name>"));
5382        assert!(!body2.contains("<Name>a</Name>"));
5383        assert!(!body2.contains("<Name>b</Name>"));
5384        // page 2 has more (e remains) so still emits a token
5385        assert!(body2.contains("<ContinuationToken>"));
5386
5387        // page 3: should be e + no continuation
5388        let start = body2.find("<ContinuationToken>").unwrap() + "<ContinuationToken>".len();
5389        let end = body2.find("</ContinuationToken>").unwrap();
5390        let token2 = body2[start..end].to_string();
5391        let req3 = make_request(
5392            Method::GET,
5393            "/",
5394            &[("max-buckets", "2"), ("continuation-token", &token2)],
5395            b"",
5396        );
5397        let resp3 = svc.list_buckets("123456789012", &req3).unwrap();
5398        let body3 = std::str::from_utf8(resp3.body.expect_bytes()).unwrap();
5399        assert!(body3.contains("<Name>e</Name>"));
5400        assert!(!body3.contains("<ContinuationToken>"));
5401    }
5402
5403    #[test]
5404    fn list_buckets_invalid_max_buckets_errors() {
5405        let svc = make_service();
5406        let req = make_request(Method::GET, "/", &[("max-buckets", "0")], b"");
5407        assert_aws_err(svc.list_buckets("123456789012", &req), "InvalidArgument");
5408
5409        let req2 = make_request(Method::GET, "/", &[("max-buckets", "20000")], b"");
5410        assert_aws_err(svc.list_buckets("123456789012", &req2), "InvalidArgument");
5411
5412        let req3 = make_request(Method::GET, "/", &[("max-buckets", "abc")], b"");
5413        assert_aws_err(svc.list_buckets("123456789012", &req3), "InvalidArgument");
5414    }
5415
5416    #[test]
5417    fn list_buckets_invalid_continuation_token_errors() {
5418        let svc = make_service();
5419        let req = make_request(
5420            Method::GET,
5421            "/",
5422            &[("continuation-token", "!!!notb64!!!")],
5423            b"",
5424        );
5425        assert_aws_err(svc.list_buckets("123456789012", &req), "InvalidArgument");
5426
5427        let req2 = make_request(Method::GET, "/", &[("continuation-token", "")], b"");
5428        assert_aws_err(svc.list_buckets("123456789012", &req2), "InvalidArgument");
5429    }
5430
5431    #[test]
5432    fn create_bucket_invalid_name_errors() {
5433        let svc = make_service();
5434        let req = make_request(Method::PUT, "/AB", &[], b"");
5435        assert_aws_err(
5436            svc.create_bucket("123456789012", &req, "AB"),
5437            "InvalidBucketName",
5438        );
5439    }
5440
5441    #[test]
5442    fn create_bucket_idempotent_same_region_us_east_1() {
5443        let svc = make_service();
5444        let req = make_request(Method::PUT, "/idem", &[], b"");
5445        svc.create_bucket("123456789012", &req, "idem").unwrap();
5446        let resp = svc.create_bucket("123456789012", &req, "idem").unwrap();
5447        assert_eq!(resp.status, StatusCode::OK);
5448    }
5449
5450    #[test]
5451    fn create_bucket_already_owned_other_region() {
5452        let svc = make_service();
5453        let mut req = make_request(
5454            Method::PUT,
5455            "/bk1",
5456            &[],
5457            b"<CreateBucketConfiguration><LocationConstraint>eu-west-1</LocationConstraint></CreateBucketConfiguration>",
5458        );
5459        req.region = "eu-west-1".to_string();
5460        svc.create_bucket("123456789012", &req, "bk1").unwrap();
5461        assert_aws_err(
5462            svc.create_bucket("123456789012", &req, "bk1"),
5463            "BucketAlreadyOwnedByYou",
5464        );
5465    }
5466
5467    #[test]
5468    fn create_bucket_us_east_1_with_explicit_constraint_invalid() {
5469        let svc = make_service();
5470        let req = make_request(
5471            Method::PUT,
5472            "/bk2",
5473            &[],
5474            b"<CreateBucketConfiguration><LocationConstraint>us-east-1</LocationConstraint></CreateBucketConfiguration>",
5475        );
5476        assert_aws_err(
5477            svc.create_bucket("123456789012", &req, "bk2"),
5478            "InvalidLocationConstraint",
5479        );
5480    }
5481
5482    #[test]
5483    fn create_bucket_constraint_mismatch_region_errors() {
5484        let svc = make_service();
5485        let mut req = make_request(
5486            Method::PUT,
5487            "/bk3",
5488            &[],
5489            b"<CreateBucketConfiguration><LocationConstraint>us-west-2</LocationConstraint></CreateBucketConfiguration>",
5490        );
5491        req.region = "eu-west-1".to_string();
5492        assert_aws_err(
5493            svc.create_bucket("123456789012", &req, "bk3"),
5494            "IllegalLocationConstraintException",
5495        );
5496    }
5497
5498    #[test]
5499    fn create_bucket_missing_constraint_in_non_default_region_errors() {
5500        let svc = make_service();
5501        let mut req = make_request(Method::PUT, "/bk4", &[], b"");
5502        req.region = "eu-west-1".to_string();
5503        assert_aws_err(
5504            svc.create_bucket("123456789012", &req, "bk4"),
5505            "IllegalLocationConstraintException",
5506        );
5507    }
5508
5509    #[test]
5510    fn create_bucket_invalid_region_constraint() {
5511        let svc = make_service();
5512        let req = make_request(
5513            Method::PUT,
5514            "/bk5",
5515            &[],
5516            b"<CreateBucketConfiguration><LocationConstraint>not-a-region</LocationConstraint></CreateBucketConfiguration>",
5517        );
5518        assert_aws_err(
5519            svc.create_bucket("123456789012", &req, "bk5"),
5520            "InvalidLocationConstraint",
5521        );
5522    }
5523
5524    #[test]
5525    fn create_bucket_with_us_east_1_constraint_when_region_not_matching() {
5526        let svc = make_service();
5527        let mut req = make_request(
5528            Method::PUT,
5529            "/bk6",
5530            &[],
5531            b"<CreateBucketConfiguration><LocationConstraint>us-east-1</LocationConstraint></CreateBucketConfiguration>",
5532        );
5533        req.region = "eu-west-1".to_string();
5534        assert_aws_err(
5535            svc.create_bucket("123456789012", &req, "bk6"),
5536            "IllegalLocationConstraintException",
5537        );
5538    }
5539
5540    #[test]
5541    fn create_bucket_with_object_lock_enables_versioning() {
5542        let svc = make_service();
5543        let mut req = make_request(Method::PUT, "/olb", &[], b"");
5544        req.headers
5545            .insert("x-amz-bucket-object-lock-enabled", "true".parse().unwrap());
5546        svc.create_bucket("123456789012", &req, "olb").unwrap();
5547        let accts = svc.state.read();
5548        let state = accts.get("123456789012").unwrap();
5549        let b = state.buckets.get("olb").unwrap();
5550        assert_eq!(b.versioning.as_deref(), Some("Enabled"));
5551        assert!(b
5552            .object_lock_config
5553            .as_deref()
5554            .unwrap_or("")
5555            .contains("ObjectLockEnabled"));
5556    }
5557
5558    #[test]
5559    fn create_bucket_with_object_ownership_header() {
5560        let svc = make_service();
5561        let mut req = make_request(Method::PUT, "/own", &[], b"");
5562        req.headers.insert(
5563            "x-amz-object-ownership",
5564            "BucketOwnerEnforced".parse().unwrap(),
5565        );
5566        svc.create_bucket("123456789012", &req, "own").unwrap();
5567        let accts = svc.state.read();
5568        let state = accts.get("123456789012").unwrap();
5569        let b = state.buckets.get("own").unwrap();
5570        assert!(b
5571            .ownership_controls
5572            .as_deref()
5573            .unwrap_or("")
5574            .contains("BucketOwnerEnforced"));
5575    }
5576
5577    #[test]
5578    fn create_bucket_with_canned_acl_public_read() {
5579        let svc = make_service();
5580        let mut req = make_request(Method::PUT, "/prb", &[], b"");
5581        req.headers
5582            .insert("x-amz-acl", "public-read".parse().unwrap());
5583        svc.create_bucket("123456789012", &req, "prb").unwrap();
5584        let accts = svc.state.read();
5585        let state = accts.get("123456789012").unwrap();
5586        let b = state.buckets.get("prb").unwrap();
5587        assert!(!b.acl_grants.is_empty());
5588    }
5589
5590    #[test]
5591    fn delete_bucket_nonexistent_errors() {
5592        let svc = make_service();
5593        let req = make_request(Method::DELETE, "/nope", &[], b"");
5594        assert_aws_err(
5595            svc.delete_bucket("123456789012", &req, "nope"),
5596            "NoSuchBucket",
5597        );
5598    }
5599
5600    #[test]
5601    fn delete_bucket_not_empty_errors() {
5602        let svc = make_service();
5603        seed_bucket(&svc, "full");
5604        seed_object(&svc, "full", "k", b"x");
5605        let req = make_request(Method::DELETE, "/full", &[], b"");
5606        assert_aws_err(
5607            svc.delete_bucket("123456789012", &req, "full"),
5608            "BucketNotEmpty",
5609        );
5610    }
5611
5612    #[test]
5613    fn delete_bucket_with_versions_not_empty_errors() {
5614        let svc = make_service();
5615        seed_bucket(&svc, "ver");
5616        {
5617            let mut mas = svc.state.write();
5618            let state = mas.default_mut();
5619            let b = state.buckets.get_mut("ver").unwrap();
5620            b.object_versions.insert(
5621                "k".to_string(),
5622                vec![S3Object {
5623                    key: "k".to_string(),
5624                    body: fakecloud_persistence::BodyRef::Memory(Bytes::from_static(b"v")),
5625                    content_type: "text/plain".to_string(),
5626                    etag: "\"abc\"".to_string(),
5627                    size: 1,
5628                    last_modified: chrono::Utc::now(),
5629                    ..Default::default()
5630                }],
5631            );
5632        }
5633        let req = make_request(Method::DELETE, "/ver", &[], b"");
5634        assert_aws_err(
5635            svc.delete_bucket("123456789012", &req, "ver"),
5636            "BucketNotEmpty",
5637        );
5638    }
5639
5640    #[test]
5641    fn delete_bucket_empty_succeeds() {
5642        let svc = make_service();
5643        seed_bucket(&svc, "empty");
5644        let req = make_request(Method::DELETE, "/empty", &[], b"");
5645        let resp = svc.delete_bucket("123456789012", &req, "empty").unwrap();
5646        assert_eq!(resp.status, StatusCode::NO_CONTENT);
5647    }
5648
5649    #[test]
5650    fn head_bucket_missing_errors() {
5651        let svc = make_service();
5652        assert_aws_err(svc.head_bucket("123456789012", "nope"), "NoSuchBucket");
5653    }
5654
5655    #[test]
5656    fn head_bucket_exists_returns_ok() {
5657        let svc = make_service();
5658        seed_bucket(&svc, "hb");
5659        let resp = svc.head_bucket("123456789012", "hb").unwrap();
5660        assert_eq!(resp.status, StatusCode::OK);
5661    }
5662
5663    #[test]
5664    fn get_bucket_location_us_east_1_returns_empty() {
5665        let svc = make_service();
5666        seed_bucket(&svc, "loc");
5667        let resp = svc.get_bucket_location("123456789012", "loc").unwrap();
5668        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5669        assert!(body.contains("<LocationConstraint"));
5670        assert!(body.contains("></LocationConstraint>"));
5671    }
5672
5673    #[test]
5674    fn get_bucket_location_other_region_returns_region() {
5675        let svc = make_service();
5676        {
5677            let mut mas = svc.state.write();
5678            let state = mas.default_mut();
5679            state
5680                .buckets
5681                .insert("eu".to_string(), S3Bucket::new("eu", "eu-west-1", "owner"));
5682        }
5683        let resp = svc.get_bucket_location("123456789012", "eu").unwrap();
5684        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5685        assert!(body.contains(">eu-west-1<"));
5686    }
5687
5688    // ── objects.rs additional coverage ──
5689
5690    #[tokio::test]
5691    async fn get_object_range_request() {
5692        let svc = make_service();
5693        seed_bucket(&svc, "range");
5694        let req = make_request(Method::PUT, "/range/k", &[], b"0123456789ABCDEF");
5695        svc.put_object("123456789012", &req, "range", "k")
5696            .await
5697            .unwrap();
5698
5699        let mut req = make_request(Method::GET, "/range/k", &[], b"");
5700        req.headers.insert("range", "bytes=2-5".parse().unwrap());
5701        let resp = svc.get_object("123456789012", &req, "range", "k").unwrap();
5702        assert_eq!(resp.status, StatusCode::PARTIAL_CONTENT);
5703        assert_eq!(resp.body.expect_bytes(), b"2345");
5704    }
5705
5706    #[tokio::test]
5707    async fn get_object_range_suffix() {
5708        let svc = make_service();
5709        seed_bucket(&svc, "rsx");
5710        let req = make_request(Method::PUT, "/rsx/k", &[], b"0123456789");
5711        svc.put_object("123456789012", &req, "rsx", "k")
5712            .await
5713            .unwrap();
5714
5715        let mut req = make_request(Method::GET, "/rsx/k", &[], b"");
5716        req.headers.insert("range", "bytes=-3".parse().unwrap());
5717        let resp = svc.get_object("123456789012", &req, "rsx", "k").unwrap();
5718        assert_eq!(resp.status, StatusCode::PARTIAL_CONTENT);
5719        assert_eq!(resp.body.expect_bytes(), b"789");
5720    }
5721
5722    #[tokio::test]
5723    async fn get_object_range_open_ended() {
5724        let svc = make_service();
5725        seed_bucket(&svc, "roe");
5726        let req = make_request(Method::PUT, "/roe/k", &[], b"0123456789");
5727        svc.put_object("123456789012", &req, "roe", "k")
5728            .await
5729            .unwrap();
5730
5731        let mut req = make_request(Method::GET, "/roe/k", &[], b"");
5732        req.headers.insert("range", "bytes=7-".parse().unwrap());
5733        let resp = svc.get_object("123456789012", &req, "roe", "k").unwrap();
5734        assert_eq!(resp.status, StatusCode::PARTIAL_CONTENT);
5735        assert_eq!(resp.body.expect_bytes(), b"789");
5736    }
5737
5738    #[tokio::test]
5739    async fn get_object_range_invalid_format() {
5740        let svc = make_service();
5741        seed_bucket(&svc, "rinv");
5742        let req = make_request(Method::PUT, "/rinv/k", &[], b"hello");
5743        svc.put_object("123456789012", &req, "rinv", "k")
5744            .await
5745            .unwrap();
5746
5747        let mut req = make_request(Method::GET, "/rinv/k", &[], b"");
5748        req.headers.insert("range", "bogus=2-5".parse().unwrap());
5749        // Non-standard prefix -> full content expected
5750        let resp = svc.get_object("123456789012", &req, "rinv", "k").unwrap();
5751        assert_eq!(resp.status, StatusCode::OK);
5752        assert_eq!(resp.body.expect_bytes(), b"hello");
5753    }
5754
5755    #[tokio::test]
5756    async fn get_object_if_match_mismatch_errors() {
5757        let svc = make_service();
5758        seed_bucket(&svc, "ifm");
5759        let req = make_request(Method::PUT, "/ifm/k", &[], b"abc");
5760        svc.put_object("123456789012", &req, "ifm", "k")
5761            .await
5762            .unwrap();
5763
5764        let mut req = make_request(Method::GET, "/ifm/k", &[], b"");
5765        req.headers
5766            .insert("if-match", "\"nomatch\"".parse().unwrap());
5767        let err = svc.get_object("123456789012", &req, "ifm", "k");
5768        assert_aws_err(err, "PreconditionFailed");
5769    }
5770
5771    #[tokio::test]
5772    async fn get_object_if_none_match_star_not_modified() {
5773        let svc = make_service();
5774        seed_bucket(&svc, "inm");
5775        let req = make_request(Method::PUT, "/inm/k", &[], b"abc");
5776        svc.put_object("123456789012", &req, "inm", "k")
5777            .await
5778            .unwrap();
5779
5780        let mut req = make_request(Method::GET, "/inm/k", &[], b"");
5781        req.headers.insert("if-none-match", "*".parse().unwrap());
5782        let err = svc.get_object("123456789012", &req, "inm", "k");
5783        assert!(err.is_err());
5784    }
5785
5786    #[tokio::test]
5787    async fn head_object_range_request() {
5788        let svc = make_service();
5789        seed_bucket(&svc, "hrng");
5790        let req = make_request(Method::PUT, "/hrng/k", &[], b"0123456789");
5791        svc.put_object("123456789012", &req, "hrng", "k")
5792            .await
5793            .unwrap();
5794
5795        let mut req = make_request(Method::HEAD, "/hrng/k", &[], b"");
5796        req.headers.insert("range", "bytes=2-5".parse().unwrap());
5797        let resp = svc.head_object("123456789012", &req, "hrng", "k").unwrap();
5798        assert_eq!(resp.status, StatusCode::PARTIAL_CONTENT);
5799    }
5800
5801    #[tokio::test]
5802    async fn put_object_with_metadata_headers() {
5803        let svc = make_service();
5804        seed_bucket(&svc, "meta");
5805        let mut req = make_request(Method::PUT, "/meta/k", &[], b"x");
5806        req.headers
5807            .insert("x-amz-meta-user", "alice".parse().unwrap());
5808        req.headers
5809            .insert("x-amz-meta-env", "prod".parse().unwrap());
5810        svc.put_object("123456789012", &req, "meta", "k")
5811            .await
5812            .unwrap();
5813
5814        let req = make_request(Method::HEAD, "/meta/k", &[], b"");
5815        let resp = svc.head_object("123456789012", &req, "meta", "k").unwrap();
5816        assert_eq!(resp.headers.get("x-amz-meta-user").unwrap(), "alice");
5817        assert_eq!(resp.headers.get("x-amz-meta-env").unwrap(), "prod");
5818    }
5819
5820    #[tokio::test]
5821    async fn put_object_with_storage_class_header() {
5822        let svc = make_service();
5823        seed_bucket(&svc, "stor");
5824        let mut req = make_request(Method::PUT, "/stor/k", &[], b"x");
5825        req.headers
5826            .insert("x-amz-storage-class", "STANDARD_IA".parse().unwrap());
5827        svc.put_object("123456789012", &req, "stor", "k")
5828            .await
5829            .unwrap();
5830
5831        let req = make_request(Method::HEAD, "/stor/k", &[], b"");
5832        let resp = svc.head_object("123456789012", &req, "stor", "k").unwrap();
5833        assert_eq!(
5834            resp.headers.get("x-amz-storage-class").unwrap(),
5835            "STANDARD_IA"
5836        );
5837    }
5838
5839    #[tokio::test]
5840    async fn put_object_with_website_redirect() {
5841        let svc = make_service();
5842        seed_bucket(&svc, "wr");
5843        let mut req = make_request(Method::PUT, "/wr/k", &[], b"x");
5844        req.headers.insert(
5845            "x-amz-website-redirect-location",
5846            "/elsewhere".parse().unwrap(),
5847        );
5848        svc.put_object("123456789012", &req, "wr", "k")
5849            .await
5850            .unwrap();
5851        let req = make_request(Method::GET, "/wr/k", &[], b"");
5852        let resp = svc.get_object("123456789012", &req, "wr", "k").unwrap();
5853        assert_eq!(
5854            resp.headers.get("x-amz-website-redirect-location").unwrap(),
5855            "/elsewhere"
5856        );
5857    }
5858
5859    #[test]
5860    fn delete_object_nonexistent_is_ok() {
5861        let svc = make_service();
5862        seed_bucket(&svc, "dne");
5863        let req = make_request(Method::DELETE, "/dne/missing", &[], b"");
5864        let resp = svc
5865            .delete_object("123456789012", &req, "dne", "missing")
5866            .unwrap();
5867        assert_eq!(resp.status, StatusCode::NO_CONTENT);
5868    }
5869
5870    #[test]
5871    fn delete_object_bucket_not_found() {
5872        let svc = make_service();
5873        let req = make_request(Method::DELETE, "/nope/k", &[], b"");
5874        assert_aws_err(
5875            svc.delete_object("123456789012", &req, "nope", "k"),
5876            "NoSuchBucket",
5877        );
5878    }
5879
5880    #[tokio::test]
5881    async fn list_objects_v2_with_prefix_and_delimiter() {
5882        let svc = make_service();
5883        seed_bucket(&svc, "pfxd");
5884        for k in &["a/1", "a/2", "b/1"] {
5885            let req = make_request(Method::PUT, &format!("/pfxd/{k}"), &[], b"x");
5886            svc.put_object("123456789012", &req, "pfxd", k)
5887                .await
5888                .unwrap();
5889        }
5890        let req = make_request(
5891            Method::GET,
5892            "/pfxd",
5893            &[("list-type", "2"), ("prefix", "a/"), ("delimiter", "/")],
5894            b"",
5895        );
5896        let resp = svc.list_objects_v2("123456789012", &req, "pfxd").unwrap();
5897        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5898        assert!(body.contains("<Contents>"));
5899    }
5900
5901    #[tokio::test]
5902    async fn list_objects_v1_basic() {
5903        let svc = make_service();
5904        seed_bucket(&svc, "v1");
5905        for k in &["a", "b"] {
5906            let req = make_request(Method::PUT, &format!("/v1/{k}"), &[], b"x");
5907            svc.put_object("123456789012", &req, "v1", k).await.unwrap();
5908        }
5909        let req = make_request(Method::GET, "/v1", &[], b"");
5910        let resp = svc.list_objects_v1("123456789012", &req, "v1").unwrap();
5911        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
5912        assert!(body.contains("<ListBucketResult"));
5913        assert!(body.contains("<Key>a</Key>"));
5914        assert!(body.contains("<Key>b</Key>"));
5915    }
5916
5917    #[test]
5918    fn get_object_key_not_found() {
5919        let svc = make_service();
5920        seed_bucket(&svc, "gkn");
5921        let req = make_request(Method::GET, "/gkn/missing", &[], b"");
5922        assert_aws_err(
5923            svc.get_object("123456789012", &req, "gkn", "missing"),
5924            "NoSuchKey",
5925        );
5926    }
5927
5928    #[test]
5929    fn get_object_bucket_not_found() {
5930        let svc = make_service();
5931        let req = make_request(Method::GET, "/ghost/k", &[], b"");
5932        assert_aws_err(
5933            svc.get_object("123456789012", &req, "ghost", "k"),
5934            "NoSuchBucket",
5935        );
5936    }
5937
5938    // ── restore_object ──
5939
5940    #[tokio::test]
5941    async fn restore_object_non_archival_errors() {
5942        let svc = make_service();
5943        seed_bucket(&svc, "roc");
5944        let req = make_request(Method::PUT, "/roc/k", &[], b"x");
5945        svc.put_object("123456789012", &req, "roc", "k")
5946            .await
5947            .unwrap();
5948
5949        let req = make_request(Method::POST, "/roc/k", &[("restore", "")], b"");
5950        assert_aws_err(
5951            svc.restore_object("123456789012", &req, "roc", "k"),
5952            "InvalidObjectState",
5953        );
5954    }
5955
5956    #[tokio::test]
5957    async fn restore_object_glacier_accepted() {
5958        let svc = make_service();
5959        seed_bucket(&svc, "rog");
5960        let mut req = make_request(Method::PUT, "/rog/k", &[], b"x");
5961        req.headers
5962            .insert("x-amz-storage-class", "GLACIER".parse().unwrap());
5963        svc.put_object("123456789012", &req, "rog", "k")
5964            .await
5965            .unwrap();
5966
5967        let req = make_request(Method::POST, "/rog/k", &[("restore", "")], b"");
5968        let resp = svc
5969            .restore_object("123456789012", &req, "rog", "k")
5970            .unwrap();
5971        assert_eq!(resp.status, StatusCode::ACCEPTED);
5972    }
5973
5974    #[test]
5975    fn restore_object_nonexistent_key() {
5976        let svc = make_service();
5977        seed_bucket(&svc, "rnk");
5978        let req = make_request(Method::POST, "/rnk/ghost", &[("restore", "")], b"");
5979        assert_aws_err(
5980            svc.restore_object("123456789012", &req, "rnk", "ghost"),
5981            "NoSuchKey",
5982        );
5983    }
5984
5985    // ── list_object_versions ──
5986
5987    #[tokio::test]
5988    async fn list_object_versions_basic() {
5989        let svc = make_service();
5990        seed_bucket(&svc, "lov");
5991        {
5992            let mut mas = svc.state.write();
5993            let state = mas.default_mut();
5994            let b = state.buckets.get_mut("lov").unwrap();
5995            b.versioning = Some("Enabled".to_string());
5996        }
5997        let req = make_request(Method::PUT, "/lov/k", &[], b"v1");
5998        svc.put_object("123456789012", &req, "lov", "k")
5999            .await
6000            .unwrap();
6001        let req = make_request(Method::PUT, "/lov/k", &[], b"v2");
6002        svc.put_object("123456789012", &req, "lov", "k")
6003            .await
6004            .unwrap();
6005
6006        let req = make_request(Method::GET, "/lov", &[("versions", "")], b"");
6007        let resp = svc
6008            .list_object_versions("123456789012", &req, "lov")
6009            .unwrap();
6010        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6011        assert!(body.contains("<ListVersionsResult"));
6012    }
6013
6014    #[test]
6015    fn delete_objects_nonexistent_bucket() {
6016        let svc = make_service();
6017        let xml = b"<Delete><Object><Key>k</Key></Object></Delete>";
6018        let req = make_request(Method::POST, "/ghost", &[("delete", "")], xml);
6019        assert_aws_err(
6020            svc.delete_objects("123456789012", &req, "ghost"),
6021            "NoSuchBucket",
6022        );
6023    }
6024
6025    #[tokio::test]
6026    async fn put_object_custom_content_type() {
6027        let svc = make_service();
6028        seed_bucket(&svc, "ct");
6029        let mut req = make_request(Method::PUT, "/ct/k", &[], b"hi");
6030        req.headers
6031            .insert("content-type", "text/plain".parse().unwrap());
6032        svc.put_object("123456789012", &req, "ct", "k")
6033            .await
6034            .unwrap();
6035        let req = make_request(Method::GET, "/ct/k", &[], b"");
6036        let resp = svc.get_object("123456789012", &req, "ct", "k").unwrap();
6037        assert_eq!(resp.content_type, "text/plain");
6038    }
6039
6040    #[test]
6041    fn head_object_bucket_not_found() {
6042        let svc = make_service();
6043        let req = make_request(Method::HEAD, "/ghost/k", &[], b"");
6044        let result = svc.head_object("123456789012", &req, "ghost", "k");
6045        assert!(result.is_err());
6046    }
6047
6048    #[tokio::test]
6049    async fn get_object_attributes_basic() {
6050        let svc = make_service();
6051        seed_bucket(&svc, "goa");
6052        let req = make_request(Method::PUT, "/goa/k", &[], b"hi");
6053        svc.put_object("123456789012", &req, "goa", "k")
6054            .await
6055            .unwrap();
6056
6057        let mut req = make_request(Method::GET, "/goa/k", &[("attributes", "")], b"");
6058        req.headers.insert(
6059            "x-amz-object-attributes",
6060            "ETag,ObjectSize".parse().unwrap(),
6061        );
6062        let resp = svc
6063            .get_object_attributes("123456789012", &req, "goa", "k")
6064            .unwrap();
6065        assert_eq!(resp.status, StatusCode::OK);
6066    }
6067
6068    #[test]
6069    fn get_object_attributes_bucket_not_found() {
6070        let svc = make_service();
6071        let req = make_request(Method::GET, "/ghost/k", &[("attributes", "")], b"");
6072        let result = svc.get_object_attributes("123456789012", &req, "ghost", "k");
6073        assert!(result.is_err());
6074    }
6075
6076    #[test]
6077    fn get_object_attributes_key_not_found() {
6078        let svc = make_service();
6079        seed_bucket(&svc, "gak");
6080        let req = make_request(Method::GET, "/gak/ghost", &[("attributes", "")], b"");
6081        let result = svc.get_object_attributes("123456789012", &req, "gak", "ghost");
6082        assert!(result.is_err());
6083    }
6084
6085    // ── ACL ──
6086
6087    #[test]
6088    fn get_object_acl_bucket_not_found() {
6089        let svc = make_service();
6090        let req = make_request(Method::GET, "/ghost/k", &[("acl", "")], b"");
6091        assert!(svc
6092            .get_object_acl("123456789012", &req, "ghost", "k")
6093            .is_err());
6094    }
6095
6096    #[test]
6097    fn put_object_acl_bucket_not_found() {
6098        let svc = make_service();
6099        let mut req = make_request(Method::PUT, "/ghost/k", &[("acl", "")], b"");
6100        req.headers.insert("x-amz-acl", "private".parse().unwrap());
6101        assert!(svc
6102            .put_object_acl("123456789012", &req, "ghost", "k")
6103            .is_err());
6104    }
6105
6106    #[tokio::test]
6107    async fn get_object_acl_returns_acl_xml() {
6108        let svc = make_service();
6109        seed_bucket(&svc, "acl");
6110        let req = make_request(Method::PUT, "/acl/k", &[], b"x");
6111        svc.put_object("123456789012", &req, "acl", "k")
6112            .await
6113            .unwrap();
6114        let req = make_request(Method::GET, "/acl/k", &[("acl", "")], b"");
6115        let resp = svc
6116            .get_object_acl("123456789012", &req, "acl", "k")
6117            .unwrap();
6118        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6119        assert!(body.contains("AccessControlPolicy"));
6120    }
6121
6122    // ── object lock ──
6123
6124    #[test]
6125    fn put_object_retention_bucket_not_found() {
6126        let svc = make_service();
6127        let xml = b"<Retention><Mode>GOVERNANCE</Mode><RetainUntilDate>2030-01-01T00:00:00Z</RetainUntilDate></Retention>";
6128        let req = make_request(Method::PUT, "/ghost/k", &[("retention", "")], xml);
6129        assert!(svc
6130            .put_object_retention("123456789012", &req, "ghost", "k")
6131            .is_err());
6132    }
6133
6134    #[test]
6135    fn get_object_legal_hold_bucket_not_found() {
6136        let svc = make_service();
6137        let req = make_request(Method::GET, "/ghost/k", &[("legal-hold", "")], b"");
6138        assert!(svc
6139            .get_object_legal_hold("123456789012", &req, "ghost", "k")
6140            .is_err());
6141    }
6142
6143    #[test]
6144    fn get_object_retention_bucket_not_found() {
6145        let svc = make_service();
6146        let req = make_request(Method::GET, "/ghost/k", &[("retention", "")], b"");
6147        assert!(svc
6148            .get_object_retention("123456789012", &req, "ghost", "k")
6149            .is_err());
6150    }
6151
6152    // ── Multipart variations ──
6153
6154    #[test]
6155    fn list_multipart_uploads_nonexistent_bucket() {
6156        let svc = make_service();
6157        assert!(svc.list_multipart_uploads("123456789012", "ghost").is_err());
6158    }
6159
6160    #[test]
6161    fn list_multipart_uploads_empty() {
6162        let svc = make_service();
6163        seed_bucket(&svc, "empmp");
6164        let resp = svc.list_multipart_uploads("123456789012", "empmp").unwrap();
6165        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6166        assert!(body.contains("<ListMultipartUploadsResult"));
6167    }
6168
6169    #[test]
6170    fn put_public_access_block_bucket_not_found() {
6171        let svc = make_service();
6172        let xml = b"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls></PublicAccessBlockConfiguration>";
6173        let req = make_request(Method::PUT, "/ghost", &[("publicAccessBlock", "")], xml);
6174        assert!(svc
6175            .put_public_access_block("123456789012", &req, "ghost")
6176            .is_err());
6177    }
6178
6179    #[test]
6180    fn public_access_block_lifecycle() {
6181        let svc = make_service();
6182        seed_bucket(&svc, "pab");
6183        let xml = b"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>true</IgnorePublicAcls></PublicAccessBlockConfiguration>";
6184        let req = make_request(Method::PUT, "/pab", &[("publicAccessBlock", "")], xml);
6185        svc.put_public_access_block("123456789012", &req, "pab")
6186            .unwrap();
6187
6188        let resp = svc.get_public_access_block("123456789012", "pab").unwrap();
6189        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6190        assert!(body.contains("BlockPublicAcls"));
6191
6192        svc.delete_public_access_block("123456789012", "pab")
6193            .unwrap();
6194    }
6195
6196    #[test]
6197    fn put_bucket_replication_bucket_not_found() {
6198        let svc = make_service();
6199        let xml = b"<ReplicationConfiguration><Role>arn</Role></ReplicationConfiguration>";
6200        let req = make_request(Method::PUT, "/ghost", &[("replication", "")], xml);
6201        assert!(svc
6202            .put_bucket_replication("123456789012", &req, "ghost")
6203            .is_err());
6204    }
6205
6206    #[test]
6207    fn put_ownership_controls_bucket_not_found() {
6208        let svc = make_service();
6209        let xml = b"<OwnershipControls><Rule><ObjectOwnership>BucketOwnerEnforced</ObjectOwnership></Rule></OwnershipControls>";
6210        let req = make_request(Method::PUT, "/ghost", &[("ownershipControls", "")], xml);
6211        assert!(svc
6212            .put_bucket_ownership_controls("123456789012", &req, "ghost")
6213            .is_err());
6214    }
6215
6216    #[test]
6217    fn put_bucket_accelerate_bucket_not_found() {
6218        let svc = make_service();
6219        let xml = b"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>";
6220        let req = make_request(Method::PUT, "/ghost", &[("accelerate", "")], xml);
6221        assert!(svc
6222            .put_bucket_accelerate("123456789012", &req, "ghost")
6223            .is_err());
6224    }
6225
6226    #[test]
6227    fn get_bucket_accelerate_lifecycle() {
6228        let svc = make_service();
6229        seed_bucket(&svc, "acc");
6230        let xml = b"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>";
6231        let req = make_request(Method::PUT, "/acc", &[("accelerate", "")], xml);
6232        svc.put_bucket_accelerate("123456789012", &req, "acc")
6233            .unwrap();
6234        let resp = svc.get_bucket_accelerate("123456789012", "acc").unwrap();
6235        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6236        assert!(body.contains("AccelerateConfiguration"));
6237    }
6238
6239    #[test]
6240    fn put_bucket_website_bucket_not_found() {
6241        let svc = make_service();
6242        let xml = b"<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>";
6243        let req = make_request(Method::PUT, "/ghost", &[("website", "")], xml);
6244        assert!(svc
6245            .put_bucket_website("123456789012", &req, "ghost")
6246            .is_err());
6247    }
6248
6249    #[test]
6250    fn put_object_tagging_bucket_not_found() {
6251        let svc = make_service();
6252        let xml = b"<Tagging><TagSet></TagSet></Tagging>";
6253        let req = make_request(Method::PUT, "/ghost/k", &[("tagging", "")], xml);
6254        assert!(svc
6255            .put_object_tagging("123456789012", &req, "ghost", "k")
6256            .is_err());
6257    }
6258
6259    #[test]
6260    fn put_object_tagging_key_not_found() {
6261        let svc = make_service();
6262        seed_bucket(&svc, "pot");
6263        let xml = b"<Tagging><TagSet></TagSet></Tagging>";
6264        let req = make_request(Method::PUT, "/pot/ghost", &[("tagging", "")], xml);
6265        assert!(svc
6266            .put_object_tagging("123456789012", &req, "pot", "ghost")
6267            .is_err());
6268    }
6269
6270    #[tokio::test]
6271    async fn put_object_tagging_lifecycle() {
6272        let svc = make_service();
6273        seed_bucket(&svc, "pota");
6274        let req = make_request(Method::PUT, "/pota/k", &[], b"x");
6275        svc.put_object("123456789012", &req, "pota", "k")
6276            .await
6277            .unwrap();
6278
6279        let xml =
6280            b"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>";
6281        let req = make_request(Method::PUT, "/pota/k", &[("tagging", "")], xml);
6282        svc.put_object_tagging("123456789012", &req, "pota", "k")
6283            .unwrap();
6284
6285        let req = make_request(Method::GET, "/pota/k", &[("tagging", "")], b"");
6286        let resp = svc
6287            .get_object_tagging("123456789012", &req, "pota", "k")
6288            .unwrap();
6289        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6290        assert!(body.contains("<Key>env</Key>"));
6291
6292        svc.delete_object_tagging("123456789012", "pota", "k")
6293            .unwrap();
6294    }
6295
6296    #[test]
6297    fn delete_object_tagging_bucket_not_found() {
6298        let svc = make_service();
6299        assert!(svc
6300            .delete_object_tagging("123456789012", "ghost", "k")
6301            .is_err());
6302    }
6303
6304    #[test]
6305    fn put_bucket_tagging_bucket_not_found() {
6306        let svc = make_service();
6307        let xml = b"<Tagging><TagSet></TagSet></Tagging>";
6308        let req = make_request(Method::PUT, "/ghost", &[("tagging", "")], xml);
6309        assert!(svc
6310            .put_bucket_tagging("123456789012", &req, "ghost")
6311            .is_err());
6312    }
6313
6314    #[test]
6315    fn bucket_tagging_lifecycle() {
6316        let svc = make_service();
6317        seed_bucket(&svc, "bt");
6318        let xml =
6319            b"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>";
6320        let req = make_request(Method::PUT, "/bt", &[("tagging", "")], xml);
6321        svc.put_bucket_tagging("123456789012", &req, "bt").unwrap();
6322
6323        let req = make_request(Method::GET, "/bt", &[("tagging", "")], b"");
6324        let resp = svc.get_bucket_tagging("123456789012", &req, "bt").unwrap();
6325        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
6326        assert!(body.contains("<Key>env</Key>"));
6327
6328        let req = make_request(Method::DELETE, "/bt", &[("tagging", "")], b"");
6329        svc.delete_bucket_tagging("123456789012", &req, "bt")
6330            .unwrap();
6331    }
6332}