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_aws::arn::Arn;
10use fakecloud_core::delivery::DeliveryBus;
11use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
12use fakecloud_kms::SharedKmsState;
13use fakecloud_persistence::{MemoryS3Store, S3Store, StoreError};
14
15use base64::engine::general_purpose::STANDARD as BASE64;
16use base64::Engine as _;
17
18use crate::logging;
19use crate::state::{AclGrant, S3Bucket, S3Object, SharedS3State};
20
21mod access_points;
22mod acl;
23mod buckets;
24pub(crate) mod config;
25mod lock;
26mod multipart;
27pub(crate) mod notifications;
28mod objects;
29mod tags;
30
31// Re-export notification helpers for use in sub-modules
32#[cfg(test)]
33use notifications::replicate_object;
34pub(super) use notifications::{
35    deliver_notifications, normalize_notification_ids, normalize_replication_xml,
36    replicate_through_store,
37};
38
39// Used only within this file (parse_cors_config)
40use notifications::extract_all_xml_values;
41
42// Re-exports used only in tests
43#[cfg(test)]
44use notifications::{
45    event_matches, key_matches_filters, parse_notification_config, parse_replication_rules,
46    NotificationTargetType,
47};
48
49pub struct S3Service {
50    state: SharedS3State,
51    delivery: Arc<DeliveryBus>,
52    kms_state: Option<SharedKmsState>,
53    pub(crate) kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
54    store: Arc<dyn S3Store>,
55    /// Resolves an access key ID to its secret + principal. Used only by
56    /// POST Object (browser-form "POST Policy" upload) to verify the
57    /// `x-amz-signature` form field cryptographically — that wire format
58    /// carries no `Authorization` header, so dispatch's normal SigV4
59    /// verification path never sees it. `None` when unwired (e.g. most
60    /// unit tests): POST Policy signature checks then only accept the
61    /// `test*` root-bypass access key, matching [`fakecloud_core::auth::is_root_bypass`]'s
62    /// "always skip crypto" convention used everywhere else in the codebase.
63    pub(crate) credential_resolver: Option<Arc<dyn fakecloud_core::auth::CredentialResolver>>,
64}
65
66/// Map a [`StoreError`] from the persistence layer to a 500 InternalError
67/// response. Invoked at every mutation site when the write-through persistence
68/// call fails: the in-memory mutation has already happened, but we surface the
69/// failure to the client so they know to retry (and so logs/metrics flag it).
70pub(crate) fn persistence_error(err: StoreError) -> AwsServiceError {
71    AwsServiceError::aws_error(
72        StatusCode::INTERNAL_SERVER_ERROR,
73        "InternalError",
74        format!("persistence store error: {err}"),
75    )
76}
77
78/// Convert a filesystem IO error from a disk-backed body read into an
79/// InternalError response.
80pub(crate) fn io_to_aws(err: std::io::Error) -> AwsServiceError {
81    AwsServiceError::aws_error(
82        StatusCode::INTERNAL_SERVER_ERROR,
83        "InternalError",
84        format!("failed to read object body from disk: {err}"),
85    )
86}
87
88impl S3Service {
89    pub fn new(state: SharedS3State, delivery: Arc<DeliveryBus>) -> Self {
90        Self::with_store(state, delivery, Arc::new(MemoryS3Store::new()))
91    }
92
93    pub fn with_store(
94        state: SharedS3State,
95        delivery: Arc<DeliveryBus>,
96        store: Arc<dyn S3Store>,
97    ) -> Self {
98        Self {
99            state,
100            delivery,
101            kms_state: None,
102            kms_hook: None,
103            store,
104            credential_resolver: None,
105        }
106    }
107
108    pub fn with_kms(mut self, kms_state: SharedKmsState) -> Self {
109        self.kms_state = Some(kms_state);
110        self
111    }
112
113    pub fn with_kms_hook(mut self, hook: Arc<dyn fakecloud_core::delivery::KmsHook>) -> Self {
114        self.kms_hook = Some(hook);
115        self
116    }
117
118    /// Wire a [`fakecloud_core::auth::CredentialResolver`] so POST Object
119    /// (browser-form POST Policy upload) can verify the form-carried
120    /// `x-amz-signature` against a non-root IAM access key's real secret.
121    pub fn with_credential_resolver(
122        mut self,
123        resolver: Arc<dyn fakecloud_core::auth::CredentialResolver>,
124    ) -> Self {
125        self.credential_resolver = Some(resolver);
126        self
127    }
128
129    /// Encrypt object body bytes for SSE-KMS storage. Returns ciphertext as
130    /// raw bytes (a UTF-8 fakecloud-kms envelope) on success.
131    ///
132    /// Fail-closed: if the KMS hook reports an error (key denied, key not
133    /// found, etc.), this returns `Err` so PutObject aborts with a 500
134    /// rather than silently storing plaintext. AWS S3 has the same
135    /// behavior — `KMS.NotFoundException` and friends surface as
136    /// `AccessDenied` / `KMS.*` errors back to the caller. When no hook is
137    /// wired (legacy / in-process tests with no KMS dependency), the
138    /// plaintext is returned unchanged so existing tests keep working.
139    pub(crate) fn encrypt_object_body(
140        &self,
141        account_id: &str,
142        region: &str,
143        bucket: &str,
144        plaintext: &[u8],
145        kms_key_id: Option<&str>,
146    ) -> Result<bytes::Bytes, AwsServiceError> {
147        let Some(hook) = &self.kms_hook else {
148            return Ok(bytes::Bytes::copy_from_slice(plaintext));
149        };
150        let key = kms_key_id.filter(|k| !k.is_empty()).unwrap_or("aws/s3");
151        let bucket_arn = Arn::s3(bucket).to_string();
152        let mut ctx = std::collections::HashMap::new();
153        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
154        match hook.encrypt(account_id, region, key, plaintext, "s3.amazonaws.com", ctx) {
155            Ok(envelope) => Ok(bytes::Bytes::from(envelope.into_bytes())),
156            Err(err) => {
157                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS encrypt failed");
158                Err(AwsServiceError::aws_error(
159                    StatusCode::INTERNAL_SERVER_ERROR,
160                    "KMS.InternalFailureException",
161                    format!("Failed to encrypt object via KMS: {err}"),
162                ))
163            }
164        }
165    }
166
167    /// Decrypt object body bytes that were stored as a fakecloud-kms
168    /// envelope. Caller is expected to gate this on
169    /// `obj.sse_algorithm == Some("aws:kms")`.
170    ///
171    /// Fail-closed: when a hook is wired and the bytes look like an
172    /// envelope but don't decrypt (key revoked, malformed ciphertext),
173    /// this returns `Err` so GetObject surfaces a 500. When no hook is
174    /// wired, or the bytes aren't UTF-8 (legacy snapshots from before
175    /// the hook landed), the bytes are returned unchanged.
176    pub(crate) fn decrypt_object_body(
177        &self,
178        account_id: &str,
179        bucket: &str,
180        ciphertext: &[u8],
181    ) -> Result<bytes::Bytes, AwsServiceError> {
182        let Some(hook) = &self.kms_hook else {
183            return Ok(bytes::Bytes::copy_from_slice(ciphertext));
184        };
185        // Stored envelope is base64 ASCII; non-UTF-8 bytes are pre-hook
186        // legacy snapshots, return as-is.
187        let envelope = match std::str::from_utf8(ciphertext) {
188            Ok(s) => s,
189            Err(_) => return Ok(bytes::Bytes::copy_from_slice(ciphertext)),
190        };
191        let bucket_arn = Arn::s3(bucket).to_string();
192        let mut ctx = std::collections::HashMap::new();
193        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
194        match hook.decrypt(account_id, envelope, "s3.amazonaws.com", ctx) {
195            Ok(bytes) => Ok(bytes::Bytes::from(bytes)),
196            Err(err) => {
197                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS decrypt failed");
198                Err(AwsServiceError::aws_error(
199                    StatusCode::INTERNAL_SERVER_ERROR,
200                    "KMS.InternalFailureException",
201                    format!("Failed to decrypt object via KMS: {err}"),
202                ))
203            }
204        }
205    }
206}
207
208#[async_trait]
209impl AwsService for S3Service {
210    fn service_name(&self) -> &str {
211        "s3"
212    }
213
214    async fn handle(&self, mut req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
215        // PutObject / UploadPart enter dispatch via the streaming path
216        // with `body = Bytes::new()` and the raw HTTP body available on
217        // `req.body_stream`. Those handlers consume the stream directly
218        // — spooling chunks to disk while computing MD5 + size in
219        // constant memory — so a 1 GiB upload never materializes into
220        // RAM. Every *other* PUT-on-bucket-key the streaming dispatch
221        // flagged (PutObjectTagging, PutObjectAcl, PutObjectRetention,
222        // PutObjectLegalHold, CopyObject, …) reads a small XML/JSON
223        // body from `req.body`, so drain the stream for them.
224        let is_put_to_key = req.method == Method::PUT
225            && req.path_segments.len() >= 2
226            && req
227                .path_segments
228                .first()
229                .map(|s| !s.is_empty())
230                .unwrap_or(false);
231        let q = &req.query_params;
232        let is_put_object = is_put_to_key
233            && !q.contains_key("tagging")
234            && !q.contains_key("acl")
235            && !q.contains_key("retention")
236            && !q.contains_key("legal-hold")
237            && !q.contains_key("renameObject")
238            && !q.contains_key("encryption")
239            && !req.headers.contains_key("x-amz-copy-source");
240        // UploadPart requires both partNumber AND uploadId; checking
241        // only partNumber would skip body draining for stray PUTs that
242        // happened to carry a partNumber query param without being a
243        // real multipart upload.
244        let is_upload_part =
245            is_put_to_key && q.contains_key("partNumber") && q.contains_key("uploadId");
246        if !is_put_object && !is_upload_part {
247            if let Some(stream) = req.take_body_stream() {
248                req.body = fakecloud_core::service::drain_request_stream(stream).await?;
249            }
250        }
251
252        // Access point data plane: resolve alias -> bucket before routing.
253        access_points::resolve_access_point(self, &mut req)?;
254
255        let account_id = req.account_id.as_str();
256
257        // S3 Control endpoint (access point management).
258        let host = req
259            .headers
260            .get("host")
261            .and_then(|v| v.to_str().ok())
262            .unwrap_or("");
263        let is_control = host.to_ascii_lowercase().contains("s3-control");
264        if is_control {
265            let v1 = req.path_segments.first().map(|s| s.as_str());
266            let v2 = req.path_segments.get(1).map(|s| s.as_str());
267            let v3 = req.path_segments.get(2).map(|s| s.as_str());
268            if v1 == Some("v20180820") && v2 == Some("accesspoint") {
269                if let Some(name) = v3 {
270                    match req.method {
271                        Method::PUT => return self.create_access_point(account_id, &req, name),
272                        Method::GET => return self.get_access_point(account_id, &req, name),
273                        Method::DELETE => return self.delete_access_point(account_id, &req, name),
274                        _ => {}
275                    }
276                } else if req.method == Method::GET {
277                    return self.list_access_points(account_id, &req);
278                }
279            }
280        }
281
282        // S3 REST routing: method + path segments + query params
283        //
284        // Reject paths that start with `//` (an empty bucket segment). The
285        // path-segment splitter further down filters empty segments out,
286        // which would otherwise let a malformed URL like `PUT //my-key`
287        // collapse to a valid `PUT /my-key` (CreateBucket) and silently
288        // succeed. Real S3 rejects empty bucket segments with
289        // InvalidBucketName, so mirror that.
290        if req.raw_path.starts_with("//") {
291            return Err(AwsServiceError::aws_error(
292                StatusCode::BAD_REQUEST,
293                "InvalidBucketName",
294                "The specified bucket is not valid: bucket name cannot be empty",
295            ));
296        }
297        let bucket = req.path_segments.first().map(|s| s.as_str());
298        // Extract key from the raw path to preserve leading slashes and empty segments.
299        // The raw path is like "/bucket/key/parts" — we strip the bucket prefix.
300        let key = if let Some(b) = bucket {
301            let prefix = format!("/{b}/");
302            if req.raw_path.starts_with(&prefix) && req.raw_path.len() > prefix.len() {
303                let raw_key = &req.raw_path[prefix.len()..];
304                Some(
305                    percent_encoding::percent_decode_str(raw_key)
306                        .decode_utf8_lossy()
307                        .into_owned(),
308                )
309            } else if req.path_segments.len() > 1 {
310                let raw = req.path_segments[1..].join("/");
311                Some(
312                    percent_encoding::percent_decode_str(&raw)
313                        .decode_utf8_lossy()
314                        .into_owned(),
315                )
316            } else {
317                None
318            }
319        } else {
320            None
321        };
322
323        // Multipart upload operations (checked before main match)
324        if let Some(b) = bucket {
325            // POST /{bucket}/{key}?uploads — CreateMultipartUpload
326            if req.method == Method::POST
327                && key.is_some()
328                && req.query_params.contains_key("uploads")
329            {
330                return self.create_multipart_upload(account_id, &req, b, key.as_deref().unwrap());
331            }
332
333            // POST /{bucket}/{key}?restore
334            if req.method == Method::POST
335                && key.is_some()
336                && req.query_params.contains_key("restore")
337            {
338                return self.restore_object(account_id, &req, b, key.as_deref().unwrap());
339            }
340
341            // POST /{bucket}/{key}?uploadId=X — CompleteMultipartUpload
342            if req.method == Method::POST && key.is_some() {
343                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
344                    return self.complete_multipart_upload(
345                        account_id,
346                        &req,
347                        b,
348                        key.as_deref().unwrap(),
349                        &upload_id,
350                    );
351                }
352            }
353
354            // PUT /{bucket}/{key}?partNumber=N&uploadId=X — UploadPart or UploadPartCopy
355            if req.method == Method::PUT && key.is_some() {
356                if let (Some(part_num_str), Some(upload_id)) = (
357                    req.query_params.get("partNumber").cloned(),
358                    req.query_params.get("uploadId").cloned(),
359                ) {
360                    if let Ok(part_number) = part_num_str.parse::<i64>() {
361                        if req.headers.contains_key("x-amz-copy-source") {
362                            return self.upload_part_copy(
363                                account_id,
364                                &req,
365                                b,
366                                key.as_deref().unwrap(),
367                                &upload_id,
368                                part_number,
369                            );
370                        }
371                        return self
372                            .upload_part(
373                                account_id,
374                                &req,
375                                b,
376                                key.as_deref().unwrap(),
377                                &upload_id,
378                                part_number,
379                            )
380                            .await;
381                    }
382                }
383            }
384
385            // DELETE /{bucket}/{key}?uploadId=X — AbortMultipartUpload
386            if req.method == Method::DELETE && key.is_some() {
387                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
388                    return self.abort_multipart_upload(
389                        account_id,
390                        b,
391                        key.as_deref().unwrap(),
392                        &upload_id,
393                    );
394                }
395            }
396
397            // GET /{bucket}?uploads — ListMultipartUploads
398            if req.method == Method::GET
399                && key.is_none()
400                && req.query_params.contains_key("uploads")
401            {
402                return self.list_multipart_uploads(account_id, b, &req.query_params);
403            }
404
405            // GET /{bucket}/{key}?uploadId=X — ListParts
406            if req.method == Method::GET && key.is_some() {
407                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
408                    return self.list_parts(
409                        account_id,
410                        &req,
411                        b,
412                        key.as_deref().unwrap(),
413                        &upload_id,
414                    );
415                }
416            }
417        }
418
419        // Handle OPTIONS preflight requests (CORS)
420        if req.method == Method::OPTIONS {
421            if let Some(b_name) = bucket {
422                let cors_config = {
423                    let accounts = self.state.read();
424                    let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
425                    let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
426                    state
427                        .buckets
428                        .get(b_name)
429                        .and_then(|b| b.cors_config.clone())
430                };
431                if let Some(ref config) = cors_config {
432                    let origin = req
433                        .headers
434                        .get("origin")
435                        .and_then(|v| v.to_str().ok())
436                        .unwrap_or("");
437                    let request_method = req
438                        .headers
439                        .get("access-control-request-method")
440                        .and_then(|v| v.to_str().ok())
441                        .unwrap_or("");
442                    let rules = parse_cors_config(config);
443                    if let Some(rule) = find_cors_rule(&rules, origin, Some(request_method)) {
444                        let mut headers = HeaderMap::new();
445                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
446                            "*"
447                        } else {
448                            origin
449                        };
450                        headers.insert(
451                            "access-control-allow-origin",
452                            matched_origin
453                                .parse()
454                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
455                        );
456                        headers.insert(
457                            "access-control-allow-methods",
458                            rule.allowed_methods
459                                .join(", ")
460                                .parse()
461                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
462                        );
463                        if !rule.allowed_headers.is_empty() {
464                            let ah = if rule.allowed_headers.contains(&"*".to_string()) {
465                                req.headers
466                                    .get("access-control-request-headers")
467                                    .and_then(|v| v.to_str().ok())
468                                    .unwrap_or("*")
469                                    .to_string()
470                            } else {
471                                rule.allowed_headers.join(", ")
472                            };
473                            headers.insert(
474                                "access-control-allow-headers",
475                                ah.parse()
476                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
477                            );
478                        }
479                        if let Some(max_age) = rule.max_age_seconds {
480                            headers.insert(
481                                "access-control-max-age",
482                                max_age
483                                    .to_string()
484                                    .parse()
485                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
486                            );
487                        }
488                        return Ok(AwsResponse {
489                            status: StatusCode::OK,
490                            content_type: String::new(),
491                            body: Bytes::new().into(),
492                            headers,
493                        });
494                    }
495                }
496                return Err(AwsServiceError::aws_error(
497                    StatusCode::FORBIDDEN,
498                    "CORSResponse",
499                    "CORS is not enabled for this bucket",
500                ));
501            }
502        }
503
504        // Capture origin for CORS response headers
505        let origin_header = req
506            .headers
507            .get("origin")
508            .and_then(|v| v.to_str().ok())
509            .map(|s| s.to_string());
510
511        // Bucket-scoped sub-resource query params. If a request targets one
512        // of these without a bucket in the path, S3 returns an error rather
513        // than silently treating it as a top-level (service) operation.
514        // Real AWS rejects e.g. `GET /?tagging` with a routing/validation
515        // error; mirroring that prevents bucket-required ops from being
516        // accidentally answered by ListBuckets / WriteGetObjectResponse.
517        let bucket_subresources: &[&str] = &[
518            "tagging",
519            "acl",
520            "versioning",
521            "cors",
522            "notification",
523            "website",
524            "accelerate",
525            "publicAccessBlock",
526            "encryption",
527            "lifecycle",
528            "logging",
529            "policy",
530            "policyStatus",
531            "replication",
532            "ownershipControls",
533            "inventory",
534            "analytics",
535            "intelligent-tiering",
536            "metrics",
537            "requestPayment",
538            "abac",
539            "metadataConfiguration",
540            "metadataTable",
541            "metadataInventoryTable",
542            "metadataJournalTable",
543            "object-lock",
544            "versions",
545            "session",
546            "retention",
547            "legal-hold",
548            "attributes",
549            "torrent",
550            "renameObject",
551            "uploads",
552            "restore",
553            "select",
554            // Bucket-required ops the earlier list missed: their query
555            // markers (e.g. `?location` for GetBucketLocation,
556            // `?delete` for DeleteObjects, `?list-type=2` for
557            // ListObjectsV2) signal a bucket op so a request with the
558            // marker but no bucket segment is malformed, not a service-
559            // level call.
560            "location",
561            "delete",
562            "list-type",
563        ];
564        if bucket.is_none()
565            && bucket_subresources
566                .iter()
567                .any(|q| req.query_params.contains_key(*q))
568        {
569            return Err(AwsServiceError::aws_error(
570                StatusCode::BAD_REQUEST,
571                "InvalidBucketName",
572                "The specified bucket is not valid: bucket name is required for this operation",
573            ));
574        }
575
576        let mut result = match (&req.method, bucket, key.as_deref()) {
577            // ListBuckets: GET /
578            (&Method::GET, None, None) => {
579                if req.query_params.get("x-id").map(|s| s.as_str()) == Some("ListDirectoryBuckets")
580                {
581                    self.list_directory_buckets(account_id, &req)
582                } else {
583                    self.list_buckets(account_id, &req)
584                }
585            }
586
587            // Bucket-level operations (no key)
588            (&Method::PUT, Some(b), None) => {
589                if req.query_params.contains_key("tagging") {
590                    self.put_bucket_tagging(account_id, &req, b)
591                } else if req.query_params.contains_key("acl") {
592                    self.put_bucket_acl(account_id, &req, b)
593                } else if req.query_params.contains_key("versioning") {
594                    self.put_bucket_versioning(account_id, &req, b)
595                } else if req.query_params.contains_key("cors") {
596                    self.put_bucket_cors(account_id, &req, b)
597                } else if req.query_params.contains_key("notification") {
598                    self.put_bucket_notification(account_id, &req, b)
599                } else if req.query_params.contains_key("website") {
600                    self.put_bucket_website(account_id, &req, b)
601                } else if req.query_params.contains_key("accelerate") {
602                    self.put_bucket_accelerate(account_id, &req, b)
603                } else if req.query_params.contains_key("publicAccessBlock") {
604                    self.put_public_access_block(account_id, &req, b)
605                } else if req.query_params.contains_key("encryption") {
606                    self.put_bucket_encryption(account_id, &req, b)
607                } else if req.query_params.contains_key("lifecycle") {
608                    self.put_bucket_lifecycle(account_id, &req, b)
609                } else if req.query_params.contains_key("logging") {
610                    self.put_bucket_logging(account_id, &req, b)
611                } else if req.query_params.contains_key("policy") {
612                    self.put_bucket_policy(account_id, &req, b)
613                } else if req.query_params.contains_key("object-lock") {
614                    self.put_object_lock_config(account_id, &req, b)
615                } else if req.query_params.contains_key("replication") {
616                    self.put_bucket_replication(account_id, &req, b)
617                } else if req.query_params.contains_key("ownershipControls") {
618                    self.put_bucket_ownership_controls(account_id, &req, b)
619                } else if req.query_params.contains_key("inventory") {
620                    self.put_bucket_inventory(account_id, &req, b)
621                } else if req.query_params.contains_key("analytics") {
622                    self.put_bucket_analytics_config(account_id, &req, b)
623                } else if req.query_params.contains_key("intelligent-tiering") {
624                    self.put_bucket_intelligent_tiering_config(account_id, &req, b)
625                } else if req.query_params.contains_key("metrics") {
626                    self.put_bucket_metrics_config(account_id, &req, b)
627                } else if req.query_params.contains_key("requestPayment") {
628                    self.put_bucket_request_payment(account_id, &req, b)
629                } else if req.query_params.contains_key("abac") {
630                    self.put_bucket_abac(account_id, &req, b)
631                } else if req.query_params.contains_key("metadataInventoryTable") {
632                    self.update_bucket_metadata_inventory_table(account_id, &req, b)
633                } else if req.query_params.contains_key("metadataJournalTable") {
634                    self.update_bucket_metadata_journal_table(account_id, &req, b)
635                } else {
636                    self.create_bucket(account_id, &req, b)
637                }
638            }
639            (&Method::DELETE, Some(b), None) => {
640                if req.query_params.contains_key("tagging") {
641                    self.delete_bucket_tagging(account_id, &req, b)
642                } else if req.query_params.contains_key("cors") {
643                    self.delete_bucket_cors(account_id, b)
644                } else if req.query_params.contains_key("website") {
645                    self.delete_bucket_website(account_id, b)
646                } else if req.query_params.contains_key("publicAccessBlock") {
647                    self.delete_public_access_block(account_id, b)
648                } else if req.query_params.contains_key("encryption") {
649                    self.delete_bucket_encryption(account_id, b)
650                } else if req.query_params.contains_key("lifecycle") {
651                    self.delete_bucket_lifecycle(account_id, b)
652                } else if req.query_params.contains_key("policy") {
653                    self.delete_bucket_policy(account_id, b)
654                } else if req.query_params.contains_key("replication") {
655                    self.delete_bucket_replication(account_id, b)
656                } else if req.query_params.contains_key("ownershipControls") {
657                    self.delete_bucket_ownership_controls(account_id, b)
658                } else if req.query_params.contains_key("inventory") {
659                    self.delete_bucket_inventory(account_id, &req, b)
660                } else if req.query_params.contains_key("analytics") {
661                    self.delete_bucket_analytics_config(account_id, &req, b)
662                } else if req.query_params.contains_key("intelligent-tiering") {
663                    self.delete_bucket_intelligent_tiering_config(account_id, &req, b)
664                } else if req.query_params.contains_key("metrics") {
665                    self.delete_bucket_metrics_config(account_id, &req, b)
666                } else if req.query_params.contains_key("metadataConfiguration") {
667                    self.delete_bucket_metadata_config(account_id, b)
668                } else if req.query_params.contains_key("metadataTable") {
669                    self.delete_bucket_metadata_table_config(account_id, b)
670                } else {
671                    self.delete_bucket(account_id, &req, b)
672                }
673            }
674            (&Method::HEAD, Some(b), None) => self.head_bucket(account_id, b),
675            (&Method::GET, Some(b), None) => {
676                if req.query_params.contains_key("tagging") {
677                    self.get_bucket_tagging(account_id, &req, b)
678                } else if req.query_params.contains_key("location") {
679                    self.get_bucket_location(account_id, b)
680                } else if req.query_params.contains_key("acl") {
681                    self.get_bucket_acl(account_id, &req, b)
682                } else if req.query_params.contains_key("versioning") {
683                    self.get_bucket_versioning(account_id, b)
684                } else if req.query_params.contains_key("versions") {
685                    self.list_object_versions(account_id, &req, b)
686                } else if req.query_params.contains_key("object-lock") {
687                    self.get_object_lock_configuration(account_id, b)
688                } else if req.query_params.contains_key("cors") {
689                    self.get_bucket_cors(account_id, b)
690                } else if req.query_params.contains_key("notification") {
691                    self.get_bucket_notification(account_id, b)
692                } else if req.query_params.contains_key("website") {
693                    self.get_bucket_website(account_id, b)
694                } else if req.query_params.contains_key("accelerate") {
695                    self.get_bucket_accelerate(account_id, b)
696                } else if req.query_params.contains_key("publicAccessBlock") {
697                    self.get_public_access_block(account_id, b)
698                } else if req.query_params.contains_key("encryption") {
699                    self.get_bucket_encryption(account_id, b)
700                } else if req.query_params.contains_key("lifecycle") {
701                    self.get_bucket_lifecycle(account_id, b)
702                } else if req.query_params.contains_key("logging") {
703                    self.get_bucket_logging(account_id, b)
704                } else if req.query_params.contains_key("policy") {
705                    self.get_bucket_policy(account_id, b)
706                } else if req.query_params.contains_key("replication") {
707                    self.get_bucket_replication(account_id, b)
708                } else if req.query_params.contains_key("ownershipControls") {
709                    self.get_bucket_ownership_controls(account_id, b)
710                } else if req.query_params.contains_key("inventory") {
711                    if req.query_params.contains_key("id") {
712                        self.get_bucket_inventory(account_id, &req, b)
713                    } else {
714                        self.list_bucket_inventory_configurations(account_id, b)
715                    }
716                } else if req.query_params.contains_key("analytics") {
717                    if req.query_params.contains_key("id") {
718                        self.get_bucket_analytics_config(account_id, &req, b)
719                    } else {
720                        self.list_bucket_analytics_configurations(account_id, b)
721                    }
722                } else if req.query_params.contains_key("intelligent-tiering") {
723                    if req.query_params.contains_key("id") {
724                        self.get_bucket_intelligent_tiering_config(account_id, &req, b)
725                    } else {
726                        self.list_bucket_intelligent_tiering_configurations(account_id, b)
727                    }
728                } else if req.query_params.contains_key("metrics") {
729                    if req.query_params.contains_key("id") {
730                        self.get_bucket_metrics_config(account_id, &req, b)
731                    } else {
732                        self.list_bucket_metrics_configurations(account_id, b)
733                    }
734                } else if req.query_params.contains_key("requestPayment") {
735                    self.get_bucket_request_payment(account_id, b)
736                } else if req.query_params.contains_key("abac") {
737                    self.get_bucket_abac(account_id, b)
738                } else if req.query_params.contains_key("policyStatus") {
739                    self.get_bucket_policy_status(account_id, b)
740                } else if req.query_params.contains_key("metadataConfiguration") {
741                    self.get_bucket_metadata_config(account_id, b)
742                } else if req.query_params.contains_key("metadataTable") {
743                    self.get_bucket_metadata_table_config(account_id, b)
744                } else if req.query_params.contains_key("session") {
745                    self.create_session(account_id, &req, b)
746                } else if req.query_params.get("list-type").map(|s| s.as_str()) == Some("2") {
747                    self.list_objects_v2(account_id, &req, b)
748                } else if req.query_params.is_empty() {
749                    // If bucket has website config and no query params, serve index document
750                    let website_config = {
751                        let accounts = self.state.read();
752                        let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
753                        let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
754                        state
755                            .buckets
756                            .get(b)
757                            .and_then(|bkt| bkt.website_config.clone())
758                    };
759                    if let Some(ref config) = website_config {
760                        if let Some(index_doc) = extract_xml_value(config, "Suffix").or_else(|| {
761                            extract_xml_value(config, "IndexDocument").and_then(|inner| {
762                                let open = "<Suffix>";
763                                let close = "</Suffix>";
764                                let s = inner.find(open)? + open.len();
765                                let e = inner.find(close)?;
766                                Some(inner[s..e].trim().to_string())
767                            })
768                        }) {
769                            self.serve_website_object(account_id, &req, b, &index_doc, config)
770                        } else {
771                            self.list_objects_v1(account_id, &req, b)
772                        }
773                    } else {
774                        self.list_objects_v1(account_id, &req, b)
775                    }
776                } else {
777                    self.list_objects_v1(account_id, &req, b)
778                }
779            }
780
781            // Object-level operations
782            (&Method::PUT, Some(b), Some(k)) => {
783                if req.query_params.contains_key("tagging") {
784                    self.put_object_tagging(account_id, &req, b, k)
785                } else if req.query_params.contains_key("acl") {
786                    self.put_object_acl(account_id, &req, b, k)
787                } else if req.query_params.contains_key("retention") {
788                    self.put_object_retention(account_id, &req, b, k)
789                } else if req.query_params.contains_key("legal-hold") {
790                    self.put_object_legal_hold(account_id, &req, b, k)
791                } else if req.query_params.contains_key("renameObject") {
792                    self.rename_object(account_id, &req, b, k)
793                } else if req.query_params.contains_key("encryption") {
794                    self.update_object_encryption(account_id, &req, b, k)
795                } else if req.headers.contains_key("x-amz-copy-source") {
796                    self.copy_object(account_id, &req, b, k)
797                } else {
798                    self.put_object(account_id, &req, b, k).await
799                }
800            }
801            (&Method::GET, Some(b), Some(k)) => {
802                if req.query_params.contains_key("tagging") {
803                    self.get_object_tagging(account_id, &req, b, k)
804                } else if req.query_params.contains_key("acl") {
805                    self.get_object_acl(account_id, &req, b, k)
806                } else if req.query_params.contains_key("retention") {
807                    self.get_object_retention(account_id, &req, b, k)
808                } else if req.query_params.contains_key("legal-hold") {
809                    self.get_object_legal_hold(account_id, &req, b, k)
810                } else if req.query_params.contains_key("attributes") {
811                    self.get_object_attributes(account_id, &req, b, k)
812                } else if req.query_params.contains_key("torrent") {
813                    self.get_object_torrent(account_id, &req, b, k)
814                } else {
815                    let result = self.get_object(account_id, &req, b, k);
816                    // If object not found and bucket has website config, serve error document
817                    let is_not_found = matches!(
818                        &result,
819                        Err(e) if e.code() == "NoSuchKey"
820                    );
821                    if is_not_found {
822                        let website_config = {
823                            let accounts = self.state.read();
824                            let _empty_s3 =
825                                crate::state::S3State::new(&req.account_id, &req.region);
826                            let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
827                            state
828                                .buckets
829                                .get(b)
830                                .and_then(|bkt| bkt.website_config.clone())
831                        };
832                        if let Some(ref config) = website_config {
833                            if let Some(error_key) = extract_xml_value(config, "ErrorDocument")
834                                .and_then(|inner| {
835                                    let open = "<Key>";
836                                    let close = "</Key>";
837                                    let s = inner.find(open)? + open.len();
838                                    let e = inner.find(close)?;
839                                    Some(inner[s..e].trim().to_string())
840                                })
841                                .or_else(|| extract_xml_value(config, "Key"))
842                            {
843                                return self.serve_website_error(account_id, &req, b, &error_key);
844                            }
845                        }
846                    }
847                    result
848                }
849            }
850            (&Method::DELETE, Some(b), Some(k)) => {
851                if req.query_params.contains_key("tagging") {
852                    self.delete_object_tagging(account_id, b, k)
853                } else {
854                    self.delete_object(account_id, &req, b, k)
855                }
856            }
857            (&Method::HEAD, Some(b), Some(k)) => self.head_object(account_id, &req, b, k),
858
859            // POST /{bucket}?delete — batch delete
860            (&Method::POST, Some(b), None) if req.query_params.contains_key("delete") => {
861                self.delete_objects(account_id, &req, b)
862            }
863            (&Method::POST, Some(b), None)
864                if req.query_params.contains_key("metadataConfiguration") =>
865            {
866                self.create_bucket_metadata_config(account_id, &req, b)
867            }
868            (&Method::POST, Some(b), None) if req.query_params.contains_key("metadataTable") => {
869                self.create_bucket_metadata_table_config(account_id, &req, b)
870            }
871            (&Method::POST, Some(b), Some(k))
872                if req.query_params.get("select-type").map(|s| s.as_str()) == Some("2") =>
873            {
874                self.select_object_content(account_id, &req, b, k)
875            }
876            (&Method::POST, Some("WriteGetObjectResponse"), None) => {
877                self.write_get_object_response(account_id, &req)
878            }
879
880            // POST /{bucket} with a multipart/form-data body — POST Object
881            // (browser-form "POST Policy" upload, the target of boto3's
882            // `generate_presigned_post`). Guarded on Content-Type so it
883            // doesn't shadow the `?delete` / metadata-config arms above,
884            // which also match `(POST, Some(b), None)` but carry their own
885            // query-param guards.
886            (&Method::POST, Some(b), None)
887                if req
888                    .headers
889                    .get(http::header::CONTENT_TYPE)
890                    .and_then(|v| v.to_str().ok())
891                    .is_some_and(|ct| {
892                        ct.to_ascii_lowercase().starts_with("multipart/form-data")
893                    }) =>
894            {
895                self.post_object(account_id, &req, b).await
896            }
897
898            _ => Err(AwsServiceError::aws_error(
899                StatusCode::METHOD_NOT_ALLOWED,
900                "MethodNotAllowed",
901                "The specified method is not allowed against this resource",
902            )),
903        };
904
905        // Apply CORS headers to the response if Origin was present
906        if let (Some(ref origin), Some(b_name)) = (&origin_header, bucket) {
907            let cors_config = {
908                let accounts = self.state.read();
909                let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
910                let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
911                state
912                    .buckets
913                    .get(b_name)
914                    .and_then(|b| b.cors_config.clone())
915            };
916            if let Some(ref config) = cors_config {
917                let rules = parse_cors_config(config);
918                if let Some(rule) = find_cors_rule(&rules, origin, None) {
919                    if let Ok(ref mut resp) = result {
920                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
921                            "*"
922                        } else {
923                            origin
924                        };
925                        resp.headers.insert(
926                            "access-control-allow-origin",
927                            matched_origin
928                                .parse()
929                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
930                        );
931                        if !rule.expose_headers.is_empty() {
932                            resp.headers.insert(
933                                "access-control-expose-headers",
934                                rule.expose_headers
935                                    .join(", ")
936                                    .parse()
937                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
938                            );
939                        }
940                    }
941                }
942            }
943        }
944
945        // Write S3 access log entry if the source bucket has logging enabled
946        if let Some(b_name) = bucket {
947            let status_code = match &result {
948                Ok(resp) => resp.status.as_u16(),
949                Err(e) => e.status().as_u16(),
950            };
951            let op = logging::operation_name(&req.method, key.as_deref());
952            logging::maybe_write_access_log(
953                &self.state,
954                &self.store,
955                b_name,
956                &logging::AccessLogRequest {
957                    operation: op,
958                    key: key.as_deref(),
959                    status: status_code,
960                    request_id: &req.request_id,
961                    method: req.method.as_str(),
962                    path: &req.raw_path,
963                },
964            );
965        }
966
967        result
968    }
969
970    fn supported_actions(&self) -> &[&str] {
971        &[
972            // Buckets
973            "ListBuckets",
974            "CreateBucket",
975            "DeleteBucket",
976            "HeadBucket",
977            "GetBucketLocation",
978            // Objects
979            "PutObject",
980            "GetObject",
981            "DeleteObject",
982            "HeadObject",
983            "CopyObject",
984            "DeleteObjects",
985            "ListObjectsV2",
986            "ListObjects",
987            "ListObjectVersions",
988            "GetObjectAttributes",
989            "RestoreObject",
990            // Object properties
991            "PutObjectTagging",
992            "GetObjectTagging",
993            "DeleteObjectTagging",
994            "PutObjectAcl",
995            "GetObjectAcl",
996            "PutObjectRetention",
997            "GetObjectRetention",
998            "PutObjectLegalHold",
999            "GetObjectLegalHold",
1000            // Bucket configuration
1001            "PutBucketTagging",
1002            "GetBucketTagging",
1003            "DeleteBucketTagging",
1004            "PutBucketAcl",
1005            "GetBucketAcl",
1006            "PutBucketVersioning",
1007            "GetBucketVersioning",
1008            "PutBucketCors",
1009            "GetBucketCors",
1010            "DeleteBucketCors",
1011            "PutBucketNotificationConfiguration",
1012            "GetBucketNotificationConfiguration",
1013            "PutBucketWebsite",
1014            "GetBucketWebsite",
1015            "DeleteBucketWebsite",
1016            "PutBucketAccelerateConfiguration",
1017            "GetBucketAccelerateConfiguration",
1018            "PutPublicAccessBlock",
1019            "GetPublicAccessBlock",
1020            "DeletePublicAccessBlock",
1021            "PutBucketEncryption",
1022            "GetBucketEncryption",
1023            "DeleteBucketEncryption",
1024            "PutBucketLifecycleConfiguration",
1025            "GetBucketLifecycleConfiguration",
1026            "DeleteBucketLifecycle",
1027            "PutBucketLogging",
1028            "GetBucketLogging",
1029            "PutBucketPolicy",
1030            "GetBucketPolicy",
1031            "DeleteBucketPolicy",
1032            "PutObjectLockConfiguration",
1033            "GetObjectLockConfiguration",
1034            "PutBucketReplication",
1035            "GetBucketReplication",
1036            "DeleteBucketReplication",
1037            "PutBucketOwnershipControls",
1038            "GetBucketOwnershipControls",
1039            "DeleteBucketOwnershipControls",
1040            "PutBucketInventoryConfiguration",
1041            "GetBucketInventoryConfiguration",
1042            "DeleteBucketInventoryConfiguration",
1043            "ListBucketInventoryConfigurations",
1044            "PutBucketAnalyticsConfiguration",
1045            "GetBucketAnalyticsConfiguration",
1046            "DeleteBucketAnalyticsConfiguration",
1047            "ListBucketAnalyticsConfigurations",
1048            "PutBucketIntelligentTieringConfiguration",
1049            "GetBucketIntelligentTieringConfiguration",
1050            "DeleteBucketIntelligentTieringConfiguration",
1051            "ListBucketIntelligentTieringConfigurations",
1052            "PutBucketMetricsConfiguration",
1053            "GetBucketMetricsConfiguration",
1054            "DeleteBucketMetricsConfiguration",
1055            "ListBucketMetricsConfigurations",
1056            "PutBucketRequestPayment",
1057            "GetBucketRequestPayment",
1058            "PutBucketAbac",
1059            "GetBucketAbac",
1060            "GetBucketPolicyStatus",
1061            "CreateBucketMetadataConfiguration",
1062            "GetBucketMetadataConfiguration",
1063            "DeleteBucketMetadataConfiguration",
1064            "CreateBucketMetadataTableConfiguration",
1065            "GetBucketMetadataTableConfiguration",
1066            "DeleteBucketMetadataTableConfiguration",
1067            "UpdateBucketMetadataInventoryTableConfiguration",
1068            "UpdateBucketMetadataJournalTableConfiguration",
1069            "GetObjectTorrent",
1070            "RenameObject",
1071            "SelectObjectContent",
1072            "UpdateObjectEncryption",
1073            "WriteGetObjectResponse",
1074            "ListDirectoryBuckets",
1075            "CreateSession",
1076            // Multipart uploads
1077            "CreateMultipartUpload",
1078            "UploadPart",
1079            "UploadPartCopy",
1080            "CompleteMultipartUpload",
1081            "AbortMultipartUpload",
1082            "ListParts",
1083            "ListMultipartUploads",
1084        ]
1085    }
1086
1087    fn iam_enforceable(&self) -> bool {
1088        true
1089    }
1090
1091    /// S3 resources are either:
1092    /// - Bucket ARN (`arn:aws:s3:::bucket`) for bucket-level actions
1093    /// - Object ARN (`arn:aws:s3:::bucket/key`) for object-level actions
1094    /// - Wildcard (`*`) for `ListBuckets` which doesn't target a specific
1095    ///   resource
1096    ///
1097    /// S3 ARNs notably omit the account id and region — this is the one
1098    /// AWS service that carries neither in its ARN, because bucket names
1099    /// are globally unique.
1100    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
1101        // S3 doesn't set `request.action` — the handler dispatches on
1102        // method + path + query params directly. Re-derive the action
1103        // name here so enforcement can match against IAM policies the
1104        // same way the real service would.
1105        let bucket = request.path_segments.first().map(|s| s.as_str());
1106        let key = if request.path_segments.len() > 1 {
1107            Some(request.path_segments[1..].join("/"))
1108        } else {
1109            None
1110        };
1111        // S3 Control access-point management (host `s3-control...`, path
1112        // `/v20180820/accesspoint[/name]`) is authorized under the dedicated
1113        // s3:CreateAccessPoint / GetAccessPoint / DeleteAccessPoint /
1114        // ListAccessPoints actions — NOT the object-level GetObject/PutObject/
1115        // DeleteObject the generic method-based detection would pick, which made
1116        // access-point policies ineffective.
1117        let host = request
1118            .headers
1119            .get("host")
1120            .and_then(|v| v.to_str().ok())
1121            .unwrap_or("");
1122        if host.to_ascii_lowercase().contains("s3-control")
1123            && request.path_segments.first().map(|s| s.as_str()) == Some("v20180820")
1124            && request.path_segments.get(1).map(|s| s.as_str()) == Some("accesspoint")
1125        {
1126            let has_name = request.path_segments.get(2).is_some();
1127            let ap_action = match (request.method.as_str(), has_name) {
1128                ("PUT", true) => Some("CreateAccessPoint"),
1129                ("GET", true) => Some("GetAccessPoint"),
1130                ("DELETE", true) => Some("DeleteAccessPoint"),
1131                ("GET", false) => Some("ListAccessPoints"),
1132                _ => None,
1133            };
1134            if let Some(action) = ap_action {
1135                let resource = request
1136                    .path_segments
1137                    .get(2)
1138                    .map(|name| format!("arn:aws:s3:::accesspoint/{name}"))
1139                    .unwrap_or_else(|| "*".to_string());
1140                return Some(fakecloud_core::auth::IamAction {
1141                    service: "s3",
1142                    action,
1143                    resource,
1144                });
1145            }
1146        }
1147        let action = s3_detect_action(
1148            request.method.as_str(),
1149            bucket,
1150            key.as_deref(),
1151            &request.query_params,
1152        )?;
1153        // A handful of S3-adjacent ops are authorized under sibling IAM
1154        // service prefixes, not `s3:`. Select the right prefix so a policy
1155        // targeting the real action string actually matches.
1156        let service = match action {
1157            "CreateSession" | "ListAllMyDirectoryBuckets" => "s3express",
1158            "WriteGetObjectResponse" => "s3-object-lambda",
1159            _ => "s3",
1160        };
1161        let resource = s3_resource_for(action, bucket, key.as_deref());
1162        Some(fakecloud_core::auth::IamAction {
1163            service,
1164            action,
1165            resource,
1166        })
1167    }
1168
1169    fn iam_condition_keys_for(
1170        &self,
1171        request: &AwsRequest,
1172        action: &fakecloud_core::auth::IamAction,
1173    ) -> std::collections::BTreeMap<String, Vec<String>> {
1174        s3_condition_keys(action.action, &request.query_params)
1175    }
1176
1177    fn resource_tags_for(
1178        &self,
1179        resource_arn: &str,
1180    ) -> Option<std::collections::HashMap<String, String>> {
1181        s3_resource_tags(&self.state, resource_arn)
1182            .map(|m| m.into_iter().collect::<std::collections::HashMap<_, _>>())
1183    }
1184
1185    fn request_tags_from(
1186        &self,
1187        request: &AwsRequest,
1188        action: &str,
1189    ) -> Option<std::collections::HashMap<String, String>> {
1190        s3_request_tags(request, action)
1191            .map(|m| m.into_iter().collect::<std::collections::HashMap<_, _>>())
1192    }
1193}
1194
1195/// Extract service-specific IAM condition keys from an S3 request.
1196///
1197/// Today only `ListObjects` / `ListObjectsV2` expose keys (`s3:prefix`,
1198/// `s3:delimiter`, `s3:max-keys`) via their query params. Other actions
1199/// return an empty map so the evaluator's safe-fail semantics treat any
1200/// policy condition referencing an unknown key as "doesn't apply".
1201fn s3_condition_keys(
1202    action: &str,
1203    query: &std::collections::HashMap<String, String>,
1204) -> std::collections::BTreeMap<String, Vec<String>> {
1205    let mut out = std::collections::BTreeMap::new();
1206    if matches!(action, "ListObjects" | "ListObjectsV2") {
1207        // Both list variants share the same query param shape.
1208        if let Some(prefix) = query.get("prefix") {
1209            out.insert("s3:prefix".to_string(), vec![prefix.clone()]);
1210        }
1211        if let Some(delimiter) = query.get("delimiter") {
1212            out.insert("s3:delimiter".to_string(), vec![delimiter.clone()]);
1213        }
1214        if let Some(max_keys) = query.get("max-keys") {
1215            out.insert("s3:max-keys".to_string(), vec![max_keys.clone()]);
1216        }
1217    }
1218    out
1219}
1220
1221/// Look up resource tags for an S3 ARN.
1222///
1223/// Bucket-level ARN (`arn:aws:s3:::bucket`) -> bucket tags.
1224/// Object-level ARN (`arn:aws:s3:::bucket/key`) -> object tags.
1225/// `*` (ListBuckets) -> `Some(empty)` (no resource to tag).
1226fn s3_resource_tags(
1227    state: &SharedS3State,
1228    resource_arn: &str,
1229) -> Option<std::collections::BTreeMap<String, String>> {
1230    if resource_arn == "*" {
1231        return Some(std::collections::BTreeMap::new());
1232    }
1233    // S3 ARNs: arn:aws:s3:::bucket or arn:aws:s3:::bucket/key
1234    let after_prefix = resource_arn.strip_prefix("arn:aws:s3:::")?;
1235    let mas = state.read();
1236    // S3 bucket names are globally unique; scan all accounts to find the bucket
1237    let bucket_name = after_prefix.split('/').next().unwrap_or(after_prefix);
1238    let state = mas
1239        .find_account(|s| s.buckets.contains_key(bucket_name))
1240        .and_then(|id| mas.get(id))
1241        .or_else(|| Some(mas.default_ref()))?;
1242    if let Some(slash_pos) = after_prefix.find('/') {
1243        // Object-level: bucket/key
1244        let bucket_name = &after_prefix[..slash_pos];
1245        let key = &after_prefix[slash_pos + 1..];
1246        let bucket = state.buckets.get(bucket_name)?;
1247        // Try current objects first, then versioned objects (latest version)
1248        if let Some(obj) = bucket.objects.get(key) {
1249            Some(obj.tags.clone())
1250        } else if let Some(versions) = bucket.object_versions.get(key) {
1251            versions.last().map(|v| v.tags.clone())
1252        } else {
1253            // Object doesn't exist yet (e.g. PutObject creating it)
1254            Some(std::collections::BTreeMap::new())
1255        }
1256    } else {
1257        // Bucket-level
1258        let bucket = state.buckets.get(after_prefix)?;
1259        Some(bucket.tags.clone())
1260    }
1261}
1262
1263/// Extract tags from an S3 request body/headers.
1264///
1265/// S3 sends tags via:
1266/// - `x-amz-tagging` header (URL-encoded `key=value&...`) on PutObject
1267/// - XML body on PutBucketTagging / PutObjectTagging
1268fn s3_request_tags(
1269    request: &AwsRequest,
1270    action: &str,
1271) -> Option<std::collections::BTreeMap<String, String>> {
1272    match action {
1273        "PutObject" | "CopyObject" | "CreateMultipartUpload" => {
1274            // Tags come via x-amz-tagging header
1275            if let Some(tagging) = request.headers.get("x-amz-tagging") {
1276                let tags = parse_url_encoded_tags(tagging.to_str().unwrap_or(""));
1277                Some(tags.into_iter().collect())
1278            } else {
1279                Some(std::collections::BTreeMap::new())
1280            }
1281        }
1282        "PutBucketTagging" | "PutObjectTagging" => {
1283            // Tags come in XML body
1284            let body = std::str::from_utf8(&request.body).unwrap_or("");
1285            let tags = parse_tagging_xml(body);
1286            Some(tags.into_iter().collect())
1287        }
1288        _ => Some(std::collections::BTreeMap::new()),
1289    }
1290}
1291
1292/// Derive the IAM action name from an S3 REST request. Handles the
1293/// common cases (GetObject, PutObject, DeleteObject, ListObjectsV2,
1294/// CreateBucket, ...) plus a subset of sub-resource operations
1295/// (`?acl`, `?tagging`, `?versioning`, `?policy`, `?cors`, `?website`,
1296/// `?lifecycle`, `?encryption`, `?logging`, `?notification`, `?replication`,
1297/// `?ownershipControls`, `?publicAccessBlock`, `?accelerate`, `?inventory`,
1298/// `?object-lock`, `?uploads`, `?uploadId`).
1299///
1300/// Returns `None` for requests that don't map to a known action — the
1301/// dispatch layer then skips enforcement for that request rather than
1302/// guessing (and a warn log fires via the "service is iam_enforceable
1303/// but has no mapping" branch in dispatch.rs).
1304fn s3_detect_action(
1305    method: &str,
1306    bucket: Option<&str>,
1307    key: Option<&str>,
1308    query: &std::collections::HashMap<String, String>,
1309) -> Option<&'static str> {
1310    let has = |q: &str| query.contains_key(q);
1311    let is_get = method == "GET";
1312    let is_put = method == "PUT";
1313    let is_post = method == "POST";
1314    let is_delete = method == "DELETE";
1315
1316    // Service root
1317    if bucket.is_none() {
1318        return match method {
1319            // A directory-bucket listing (`GET /?x-id=ListDirectoryBuckets`) is
1320            // authorized under `s3express:ListAllMyDirectoryBuckets`, NOT
1321            // `s3:ListBuckets`. Detecting it as ListBuckets let an
1322            // `s3:ListBuckets` grant wrongly enumerate directory buckets and
1323            // denied a policy that only granted the s3express action (bug-hunt
1324            // 2026-06-24, 5.5).
1325            "GET" if query.get("x-id").map(|s| s.as_str()) == Some("ListDirectoryBuckets") => {
1326                Some("ListAllMyDirectoryBuckets")
1327            }
1328            "GET" => Some("ListBuckets"),
1329            _ => None,
1330        };
1331    }
1332
1333    // WriteGetObjectResponse is an Object Lambda data-plane op routed as
1334    // `POST /WriteGetObjectResponse` (the literal value occupies the bucket
1335    // segment). AWS authorizes it under the separate
1336    // `s3-object-lambda:WriteGetObjectResponse` action — handled in
1337    // `iam_action_for`, which selects the `s3-object-lambda` service prefix
1338    // for this op. Previously unmapped -> enforcement skipped.
1339    if is_post && bucket == Some("WriteGetObjectResponse") && key.is_none() {
1340        return Some("WriteGetObjectResponse");
1341    }
1342
1343    let has_key = key.is_some();
1344
1345    // Multipart sub-resource forms
1346    if has_key && is_post && has("uploads") {
1347        return Some("CreateMultipartUpload");
1348    }
1349    if has_key && is_post && has("uploadId") {
1350        return Some("CompleteMultipartUpload");
1351    }
1352    if has_key && is_put && has("partNumber") && has("uploadId") {
1353        return Some("UploadPart");
1354    }
1355    if has_key && is_delete && has("uploadId") {
1356        return Some("AbortMultipartUpload");
1357    }
1358    if has_key && is_get && has("uploadId") {
1359        return Some("ListParts");
1360    }
1361    if !has_key && is_get && has("uploads") {
1362        return Some("ListMultipartUploads");
1363    }
1364
1365    // Sub-resource-keyed actions (?acl, ?tagging, ...). Order matters
1366    // since a request can carry multiple; we pick the most specific.
1367    // Object-level sub-resources come first (key present).
1368    if has_key {
1369        if has("tagging") {
1370            return Some(match method {
1371                "GET" => "GetObjectTagging",
1372                "PUT" => "PutObjectTagging",
1373                "DELETE" => "DeleteObjectTagging",
1374                _ => return None,
1375            });
1376        }
1377        if has("acl") {
1378            return Some(match method {
1379                "GET" => "GetObjectAcl",
1380                "PUT" => "PutObjectAcl",
1381                _ => return None,
1382            });
1383        }
1384        if has("retention") {
1385            return Some(match method {
1386                "GET" => "GetObjectRetention",
1387                "PUT" => "PutObjectRetention",
1388                _ => return None,
1389            });
1390        }
1391        if has("legal-hold") {
1392            return Some(match method {
1393                "GET" => "GetObjectLegalHold",
1394                "PUT" => "PutObjectLegalHold",
1395                _ => return None,
1396            });
1397        }
1398        // Identified by cubic on PR #399: both ?attributes and ?restore
1399        // need method guards — otherwise e.g. GET /bucket/key?restore
1400        // would be classified as RestoreObject (POST-only in AWS) and
1401        // IAM-evaluated against s3:RestoreObject instead of s3:GetObject.
1402        if has("attributes") && is_get {
1403            return Some("GetObjectAttributes");
1404        }
1405        if has("restore") && is_post {
1406            return Some("RestoreObject");
1407        }
1408        // RenameObject is an object-level op (`PUT /bucket/newkey?renameObject`
1409        // + `x-amz-rename-source`). It carries a key, so it must be detected
1410        // here — the `!has_key` arm below never sees it. Without this it fell
1411        // through to `PutObject`, so a policy denying `s3:RenameObject` was
1412        // silently ineffective.
1413        if has("renameObject") && is_put {
1414            return Some("RenameObject");
1415        }
1416        // UpdateObjectEncryption (`PUT /bucket/key?encryption`) changes an
1417        // existing object's server-side encryption. AWS gates this under
1418        // `s3:PutObject` (encryption is a write attribute), and there is no
1419        // distinct public `s3:UpdateObjectEncryption` IAM action — so map to
1420        // PutObject deliberately rather than letting it fall through.
1421        if has("encryption") && is_put {
1422            return Some("PutObject");
1423        }
1424        // SelectObjectContent (`POST /bucket/key?select&select-type=2`) reads
1425        // object data, so AWS authorizes it with `s3:GetObject`. Previously
1426        // unmapped -> `_ => None` -> enforcement skipped entirely.
1427        if has("select-type") && is_post {
1428            return Some("GetObject");
1429        }
1430    }
1431
1432    // Bucket-level sub-resources (key absent).
1433    if !has_key {
1434        if has("tagging") {
1435            return Some(match method {
1436                "GET" => "GetBucketTagging",
1437                "PUT" => "PutBucketTagging",
1438                "DELETE" => "DeleteBucketTagging",
1439                _ => return None,
1440            });
1441        }
1442        if has("acl") {
1443            return Some(match method {
1444                "GET" => "GetBucketAcl",
1445                "PUT" => "PutBucketAcl",
1446                _ => return None,
1447            });
1448        }
1449        if has("versioning") {
1450            return Some(match method {
1451                "GET" => "GetBucketVersioning",
1452                "PUT" => "PutBucketVersioning",
1453                _ => return None,
1454            });
1455        }
1456        if has("cors") {
1457            return Some(match method {
1458                "GET" => "GetBucketCors",
1459                "PUT" => "PutBucketCors",
1460                "DELETE" => "DeleteBucketCors",
1461                _ => return None,
1462            });
1463        }
1464        if has("policy") {
1465            return Some(match method {
1466                "GET" => "GetBucketPolicy",
1467                "PUT" => "PutBucketPolicy",
1468                "DELETE" => "DeleteBucketPolicy",
1469                _ => return None,
1470            });
1471        }
1472        if has("website") {
1473            return Some(match method {
1474                "GET" => "GetBucketWebsite",
1475                "PUT" => "PutBucketWebsite",
1476                "DELETE" => "DeleteBucketWebsite",
1477                _ => return None,
1478            });
1479        }
1480        if has("lifecycle") {
1481            return Some(match method {
1482                "GET" => "GetBucketLifecycleConfiguration",
1483                "PUT" => "PutBucketLifecycleConfiguration",
1484                "DELETE" => "DeleteBucketLifecycle",
1485                _ => return None,
1486            });
1487        }
1488        if has("encryption") {
1489            return Some(match method {
1490                "GET" => "GetBucketEncryption",
1491                "PUT" => "PutBucketEncryption",
1492                "DELETE" => "DeleteBucketEncryption",
1493                _ => return None,
1494            });
1495        }
1496        if has("logging") {
1497            return Some(match method {
1498                "GET" => "GetBucketLogging",
1499                "PUT" => "PutBucketLogging",
1500                _ => return None,
1501            });
1502        }
1503        if has("notification") {
1504            return Some(match method {
1505                "GET" => "GetBucketNotificationConfiguration",
1506                "PUT" => "PutBucketNotificationConfiguration",
1507                _ => return None,
1508            });
1509        }
1510        if has("replication") {
1511            return Some(match method {
1512                "GET" => "GetBucketReplication",
1513                "PUT" => "PutBucketReplication",
1514                "DELETE" => "DeleteBucketReplication",
1515                _ => return None,
1516            });
1517        }
1518        if has("ownershipControls") {
1519            return Some(match method {
1520                "GET" => "GetBucketOwnershipControls",
1521                "PUT" => "PutBucketOwnershipControls",
1522                "DELETE" => "DeleteBucketOwnershipControls",
1523                _ => return None,
1524            });
1525        }
1526        if has("publicAccessBlock") {
1527            return Some(match method {
1528                "GET" => "GetPublicAccessBlock",
1529                "PUT" => "PutPublicAccessBlock",
1530                "DELETE" => "DeletePublicAccessBlock",
1531                _ => return None,
1532            });
1533        }
1534        if has("accelerate") {
1535            return Some(match method {
1536                "GET" => "GetBucketAccelerateConfiguration",
1537                "PUT" => "PutBucketAccelerateConfiguration",
1538                _ => return None,
1539            });
1540        }
1541        if has("inventory") {
1542            return Some(match method {
1543                "GET" => "GetBucketInventoryConfiguration",
1544                "PUT" => "PutBucketInventoryConfiguration",
1545                "DELETE" => "DeleteBucketInventoryConfiguration",
1546                _ => return None,
1547            });
1548        }
1549        if has("analytics") {
1550            return Some(match method {
1551                "GET" if has("id") => "GetBucketAnalyticsConfiguration",
1552                "GET" => "ListBucketAnalyticsConfigurations",
1553                "PUT" => "PutBucketAnalyticsConfiguration",
1554                "DELETE" => "DeleteBucketAnalyticsConfiguration",
1555                _ => return None,
1556            });
1557        }
1558        if has("intelligent-tiering") {
1559            return Some(match method {
1560                "GET" if has("id") => "GetBucketIntelligentTieringConfiguration",
1561                "GET" => "ListBucketIntelligentTieringConfigurations",
1562                "PUT" => "PutBucketIntelligentTieringConfiguration",
1563                "DELETE" => "DeleteBucketIntelligentTieringConfiguration",
1564                _ => return None,
1565            });
1566        }
1567        if has("metrics") {
1568            return Some(match method {
1569                "GET" if has("id") => "GetBucketMetricsConfiguration",
1570                "GET" => "ListBucketMetricsConfigurations",
1571                "PUT" => "PutBucketMetricsConfiguration",
1572                "DELETE" => "DeleteBucketMetricsConfiguration",
1573                _ => return None,
1574            });
1575        }
1576        if has("requestPayment") {
1577            return Some(match method {
1578                "GET" => "GetBucketRequestPayment",
1579                "PUT" => "PutBucketRequestPayment",
1580                _ => return None,
1581            });
1582        }
1583        if has("policyStatus") && is_get {
1584            return Some("GetBucketPolicyStatus");
1585        }
1586        if has("metadataConfiguration") {
1587            return Some(match method {
1588                "GET" => "GetBucketMetadataConfiguration",
1589                "POST" => "CreateBucketMetadataConfiguration",
1590                "DELETE" => "DeleteBucketMetadataConfiguration",
1591                _ => return None,
1592            });
1593        }
1594        if has("metadataTable") {
1595            return Some(match method {
1596                "GET" => "GetBucketMetadataTableConfiguration",
1597                "POST" => "CreateBucketMetadataTableConfiguration",
1598                "DELETE" => "DeleteBucketMetadataTableConfiguration",
1599                _ => return None,
1600            });
1601        }
1602        if has("metadataInventoryTable") && is_put {
1603            return Some("UpdateBucketMetadataInventoryTableConfiguration");
1604        }
1605        if has("metadataJournalTable") && is_put {
1606            return Some("UpdateBucketMetadataJournalTableConfiguration");
1607        }
1608        if has("abac") {
1609            // PUT  -> PutBucketAbacConfiguration
1610            // GET  -> GetBucketAbacConfiguration (previously fell through to
1611            //         the plain `GET bucket` arm and was IAM-evaluated as
1612            //         s3:ListObjects — a policy on the ABAC config was never
1613            //         honored).
1614            return Some(match method {
1615                "PUT" => "PutBucketAbacConfiguration",
1616                "GET" => "GetBucketAbacConfiguration",
1617                _ => return None,
1618            });
1619        }
1620        if has("session") && is_get {
1621            // CreateSession (S3 Express directory buckets). AWS authorizes it
1622            // under the separate `s3express:CreateSession` action — handled in
1623            // `iam_action_for`, which selects the `s3express` service prefix
1624            // for this op. Previously fell through to the plain `GET bucket`
1625            // arm and was evaluated as s3:ListObjects.
1626            return Some("CreateSession");
1627        }
1628        if has("object-lock") {
1629            return Some(match method {
1630                "GET" => "GetObjectLockConfiguration",
1631                "PUT" => "PutObjectLockConfiguration",
1632                _ => return None,
1633            });
1634        }
1635        if has("location") {
1636            return Some("GetBucketLocation");
1637        }
1638        if is_post && has("delete") {
1639            return Some("DeleteObjects");
1640        }
1641        if is_get && has("versions") {
1642            return Some("ListObjectVersions");
1643        }
1644    }
1645
1646    // GET on an object with `?torrent` is GetObjectTorrent, authorized under
1647    // s3:GetObjectTorrent -- not s3:GetObject. Without this arm a policy that
1648    // allows GetObject but not GetObjectTorrent would wrongly permit it
1649    // (bug-audit 2026-06-20, 5.3).
1650    if is_get && has_key && has("torrent") {
1651        return Some("GetObjectTorrent");
1652    }
1653
1654    // Plain bucket/object methods.
1655    match (method, has_key) {
1656        ("GET", true) => Some("GetObject"),
1657        ("PUT", true) => {
1658            // CopyObject uses x-amz-copy-source but we don't have headers
1659            // handy here — treat both PutObject and CopyObject as PutObject
1660            // for IAM purposes; CopyObject additionally requires
1661            // s3:GetObject on the source but that's evaluated per-request
1662            // by real AWS, not on the PUT call itself.
1663            Some("PutObject")
1664        }
1665        ("DELETE", true) => Some("DeleteObject"),
1666        ("HEAD", true) => Some("HeadObject"),
1667        ("GET", false) => {
1668            if query.contains_key("list-type") {
1669                Some("ListObjectsV2")
1670            } else {
1671                Some("ListObjects")
1672            }
1673        }
1674        ("PUT", false) => Some("CreateBucket"),
1675        ("DELETE", false) => Some("DeleteBucket"),
1676        ("HEAD", false) => Some("HeadBucket"),
1677        _ => None,
1678    }
1679}
1680
1681/// Build the S3 resource ARN for an action. Returns `*` for
1682/// `ListBuckets` (account-scoped), a bucket ARN for bucket-level
1683/// configuration actions, or an object ARN for object-level actions.
1684fn s3_resource_for(action: &'static str, bucket: Option<&str>, key: Option<&str>) -> String {
1685    // Object-level actions work on `bucket/key`.
1686    const OBJECT_ACTIONS: &[&str] = &[
1687        "PutObject",
1688        "GetObject",
1689        "DeleteObject",
1690        "HeadObject",
1691        "CopyObject",
1692        "GetObjectAttributes",
1693        "RestoreObject",
1694        "PutObjectTagging",
1695        "GetObjectTagging",
1696        "DeleteObjectTagging",
1697        "PutObjectAcl",
1698        "GetObjectAcl",
1699        "PutObjectRetention",
1700        "GetObjectRetention",
1701        "PutObjectLegalHold",
1702        "GetObjectLegalHold",
1703        "CreateMultipartUpload",
1704        "UploadPart",
1705        "UploadPartCopy",
1706        "CompleteMultipartUpload",
1707        "AbortMultipartUpload",
1708        "ListParts",
1709    ];
1710    if action == "ListBuckets" {
1711        return "*".to_string();
1712    }
1713    let Some(bucket) = bucket else {
1714        return "*".to_string();
1715    };
1716    if OBJECT_ACTIONS.contains(&action) {
1717        match key {
1718            Some(k) if !k.is_empty() => Arn::s3(&format!("{bucket}/{k}")).to_string(),
1719            _ => Arn::s3(&format!("{bucket}/*")).to_string(),
1720        }
1721    } else {
1722        // Bucket-level actions (ListObjectsV2, GetBucketTagging, ...).
1723        Arn::s3(bucket).to_string()
1724    }
1725}
1726
1727// Conditional request helpers
1728
1729/// Truncate a DateTime to second-level precision (HTTP dates have no sub-second info).
1730pub(crate) fn truncate_to_seconds(dt: DateTime<Utc>) -> DateTime<Utc> {
1731    dt.with_nanosecond(0).unwrap_or(dt)
1732}
1733
1734pub(crate) fn check_get_conditionals(
1735    req: &AwsRequest,
1736    obj: &S3Object,
1737) -> Result<(), AwsServiceError> {
1738    let obj_etag = format!("\"{}\"", obj.etag);
1739    let obj_time = truncate_to_seconds(obj.last_modified);
1740
1741    // If-Match
1742    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1743        if !etag_matches(if_match, &obj_etag) {
1744            return Err(precondition_failed("If-Match"));
1745        }
1746    }
1747
1748    // If-None-Match
1749    if let Some(if_none_match) = req
1750        .headers
1751        .get("if-none-match")
1752        .and_then(|v| v.to_str().ok())
1753    {
1754        if etag_matches(if_none_match, &obj_etag) {
1755            return Err(not_modified_with_etag(&obj_etag));
1756        }
1757    }
1758
1759    // If-Unmodified-Since — RFC 7232 §3.4: ignored when If-Match is present.
1760    if req.headers.get("if-match").is_none() {
1761        if let Some(since) = req
1762            .headers
1763            .get("if-unmodified-since")
1764            .and_then(|v| v.to_str().ok())
1765        {
1766            if let Some(dt) = parse_http_date(since) {
1767                if obj_time > dt {
1768                    return Err(precondition_failed("If-Unmodified-Since"));
1769                }
1770            }
1771        }
1772    }
1773
1774    // If-Modified-Since — RFC 7232 §3.3: ignored when If-None-Match is present.
1775    if req.headers.get("if-none-match").is_none() {
1776        if let Some(since) = req
1777            .headers
1778            .get("if-modified-since")
1779            .and_then(|v| v.to_str().ok())
1780        {
1781            if let Some(dt) = parse_http_date(since) {
1782                if obj_time <= dt {
1783                    return Err(not_modified());
1784                }
1785            }
1786        }
1787    }
1788
1789    Ok(())
1790}
1791
1792pub(crate) fn check_head_conditionals(
1793    req: &AwsRequest,
1794    obj: &S3Object,
1795) -> Result<(), AwsServiceError> {
1796    let obj_etag = format!("\"{}\"", obj.etag);
1797    let obj_time = truncate_to_seconds(obj.last_modified);
1798
1799    // If-Match
1800    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1801        if !etag_matches(if_match, &obj_etag) {
1802            return Err(AwsServiceError::aws_error(
1803                StatusCode::PRECONDITION_FAILED,
1804                "412",
1805                "Precondition Failed",
1806            ));
1807        }
1808    }
1809
1810    // If-None-Match
1811    if let Some(if_none_match) = req
1812        .headers
1813        .get("if-none-match")
1814        .and_then(|v| v.to_str().ok())
1815    {
1816        if etag_matches(if_none_match, &obj_etag) {
1817            return Err(not_modified_with_etag(&obj_etag));
1818        }
1819    }
1820
1821    // If-Unmodified-Since — RFC 7232 §3.4: ignored when If-Match is present.
1822    if req.headers.get("if-match").is_none() {
1823        if let Some(since) = req
1824            .headers
1825            .get("if-unmodified-since")
1826            .and_then(|v| v.to_str().ok())
1827        {
1828            if let Some(dt) = parse_http_date(since) {
1829                if obj_time > dt {
1830                    return Err(AwsServiceError::aws_error(
1831                        StatusCode::PRECONDITION_FAILED,
1832                        "412",
1833                        "Precondition Failed",
1834                    ));
1835                }
1836            }
1837        }
1838    }
1839
1840    // If-Modified-Since — RFC 7232 §3.3: ignored when If-None-Match is present.
1841    if req.headers.get("if-none-match").is_none() {
1842        if let Some(since) = req
1843            .headers
1844            .get("if-modified-since")
1845            .and_then(|v| v.to_str().ok())
1846        {
1847            if let Some(dt) = parse_http_date(since) {
1848                if obj_time <= dt {
1849                    return Err(not_modified());
1850                }
1851            }
1852        }
1853    }
1854
1855    Ok(())
1856}
1857
1858pub(crate) fn etag_matches(condition: &str, obj_etag: &str) -> bool {
1859    let condition = condition.trim();
1860    if condition == "*" {
1861        return true;
1862    }
1863    let clean_etag = obj_etag.replace('"', "");
1864    // Split on comma to handle multi-value If-Match / If-None-Match
1865    for part in condition.split(',') {
1866        let part = part.trim().replace('"', "");
1867        if part == clean_etag {
1868            return true;
1869        }
1870    }
1871    false
1872}
1873
1874pub(crate) fn parse_http_date(s: &str) -> Option<DateTime<Utc>> {
1875    // Try RFC 2822 format: "Sat, 01 Jan 2000 00:00:00 GMT"
1876    if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
1877        return Some(dt.with_timezone(&Utc));
1878    }
1879    // Try RFC 3339
1880    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1881        return Some(dt.with_timezone(&Utc));
1882    }
1883    // Try common HTTP date format: "%a, %d %b %Y %H:%M:%S GMT"
1884    if let Ok(dt) =
1885        chrono::NaiveDateTime::parse_from_str(s.trim_end_matches(" GMT"), "%a, %d %b %Y %H:%M:%S")
1886    {
1887        return Some(dt.and_utc());
1888    }
1889    // Try ISO 8601
1890    if let Ok(dt) = s.parse::<DateTime<Utc>>() {
1891        return Some(dt);
1892    }
1893    None
1894}
1895
1896pub(crate) fn not_modified() -> AwsServiceError {
1897    AwsServiceError::aws_error(StatusCode::NOT_MODIFIED, "304", "Not Modified")
1898}
1899
1900pub(crate) fn not_modified_with_etag(etag: &str) -> AwsServiceError {
1901    AwsServiceError::aws_error_with_headers(
1902        StatusCode::NOT_MODIFIED,
1903        "304",
1904        "Not Modified",
1905        vec![("etag".to_string(), etag.to_string())],
1906    )
1907}
1908
1909pub(crate) fn precondition_failed(condition: &str) -> AwsServiceError {
1910    AwsServiceError::aws_error_with_fields(
1911        StatusCode::PRECONDITION_FAILED,
1912        "PreconditionFailed",
1913        "At least one of the pre-conditions you specified did not hold",
1914        vec![("Condition".to_string(), condition.to_string())],
1915    )
1916}
1917
1918// ACL helpers
1919
1920pub(crate) fn build_acl_xml(owner_id: &str, grants: &[AclGrant], _account_id: &str) -> String {
1921    let mut grants_xml = String::new();
1922    for g in grants {
1923        let grantee_xml = if g.grantee_type == "Group" {
1924            let uri = g.grantee_uri.as_deref().unwrap_or("");
1925            format!(
1926                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
1927                 <URI>{}</URI></Grantee>",
1928                xml_escape(uri),
1929            )
1930        } else {
1931            let id = g.grantee_id.as_deref().unwrap_or("");
1932            format!(
1933                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
1934                 <ID>{}</ID></Grantee>",
1935                xml_escape(id),
1936            )
1937        };
1938        grants_xml.push_str(&format!(
1939            "<Grant>{grantee_xml}<Permission>{}</Permission></Grant>",
1940            xml_escape(&g.permission),
1941        ));
1942    }
1943
1944    format!(
1945        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1946         <AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1947         <Owner><ID>{owner_id}</ID><DisplayName>{owner_id}</DisplayName></Owner>\
1948         <AccessControlList>{grants_xml}</AccessControlList>\
1949         </AccessControlPolicy>",
1950        owner_id = xml_escape(owner_id),
1951    )
1952}
1953
1954pub(crate) fn canned_acl_grants(acl: &str, owner_id: &str) -> Vec<AclGrant> {
1955    let owner_grant = AclGrant {
1956        grantee_type: "CanonicalUser".to_string(),
1957        grantee_id: Some(owner_id.to_string()),
1958        grantee_display_name: Some(owner_id.to_string()),
1959        grantee_uri: None,
1960        permission: "FULL_CONTROL".to_string(),
1961    };
1962    match acl {
1963        "private" => vec![owner_grant],
1964        "public-read" => vec![
1965            owner_grant,
1966            AclGrant {
1967                grantee_type: "Group".to_string(),
1968                grantee_id: None,
1969                grantee_display_name: None,
1970                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1971                permission: "READ".to_string(),
1972            },
1973        ],
1974        "public-read-write" => vec![
1975            owner_grant,
1976            AclGrant {
1977                grantee_type: "Group".to_string(),
1978                grantee_id: None,
1979                grantee_display_name: None,
1980                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1981                permission: "READ".to_string(),
1982            },
1983            AclGrant {
1984                grantee_type: "Group".to_string(),
1985                grantee_id: None,
1986                grantee_display_name: None,
1987                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1988                permission: "WRITE".to_string(),
1989            },
1990        ],
1991        "authenticated-read" => vec![
1992            owner_grant,
1993            AclGrant {
1994                grantee_type: "Group".to_string(),
1995                grantee_id: None,
1996                grantee_display_name: None,
1997                grantee_uri: Some(
1998                    "http://acs.amazonaws.com/groups/global/AuthenticatedUsers".to_string(),
1999                ),
2000                permission: "READ".to_string(),
2001            },
2002        ],
2003        "bucket-owner-full-control" => vec![owner_grant],
2004        _ => vec![owner_grant],
2005    }
2006}
2007
2008pub(crate) fn canned_acl_grants_for_object(acl: &str, owner_id: &str) -> Vec<AclGrant> {
2009    // For objects, canned ACLs work the same way
2010    canned_acl_grants(acl, owner_id)
2011}
2012
2013pub(crate) fn parse_grant_headers(headers: &HeaderMap) -> Vec<AclGrant> {
2014    let mut grants = Vec::new();
2015    let header_permission_map = [
2016        ("x-amz-grant-read", "READ"),
2017        ("x-amz-grant-write", "WRITE"),
2018        ("x-amz-grant-read-acp", "READ_ACP"),
2019        ("x-amz-grant-write-acp", "WRITE_ACP"),
2020        ("x-amz-grant-full-control", "FULL_CONTROL"),
2021    ];
2022
2023    for (header, permission) in &header_permission_map {
2024        if let Some(value) = headers.get(*header).and_then(|v| v.to_str().ok()) {
2025            // Parse "id=xxx" or "uri=xxx" or "emailAddress=xxx"
2026            for part in value.split(',') {
2027                let part = part.trim();
2028                if let Some((key, val)) = part.split_once('=') {
2029                    let val = val.trim().trim_matches('"');
2030                    let key = key.trim().to_lowercase();
2031                    match key.as_str() {
2032                        "id" => {
2033                            grants.push(AclGrant {
2034                                grantee_type: "CanonicalUser".to_string(),
2035                                grantee_id: Some(val.to_string()),
2036                                grantee_display_name: Some(val.to_string()),
2037                                grantee_uri: None,
2038                                permission: permission.to_string(),
2039                            });
2040                        }
2041                        "uri" | "url" => {
2042                            grants.push(AclGrant {
2043                                grantee_type: "Group".to_string(),
2044                                grantee_id: None,
2045                                grantee_display_name: None,
2046                                grantee_uri: Some(val.to_string()),
2047                                permission: permission.to_string(),
2048                            });
2049                        }
2050                        _ => {}
2051                    }
2052                }
2053            }
2054        }
2055    }
2056    grants
2057}
2058
2059pub(crate) fn parse_acl_xml(xml: &str) -> Result<Vec<AclGrant>, AwsServiceError> {
2060    // Check for Owner presence
2061    if xml.contains("<AccessControlPolicy") && !xml.contains("<Owner>") {
2062        return Err(AwsServiceError::aws_error(
2063            StatusCode::BAD_REQUEST,
2064            "MalformedACLError",
2065            "The XML you provided was not well-formed or did not validate against our published schema",
2066        ));
2067    }
2068
2069    let valid_permissions = ["READ", "WRITE", "READ_ACP", "WRITE_ACP", "FULL_CONTROL"];
2070
2071    let mut grants = Vec::new();
2072    let mut remaining = xml;
2073    while let Some(start) = remaining.find("<Grant>") {
2074        let after = &remaining[start + 7..];
2075        if let Some(end) = after.find("</Grant>") {
2076            let grant_body = &after[..end];
2077
2078            // Extract permission
2079            let permission = extract_xml_value(grant_body, "Permission").unwrap_or_default();
2080            if !valid_permissions.contains(&permission.as_str()) {
2081                return Err(AwsServiceError::aws_error(
2082                    StatusCode::BAD_REQUEST,
2083                    "MalformedACLError",
2084                    "The XML you provided was not well-formed or did not validate against our published schema",
2085                ));
2086            }
2087
2088            // Determine grantee type
2089            if grant_body.contains("xsi:type=\"Group\"") || grant_body.contains("<URI>") {
2090                let uri = extract_xml_value(grant_body, "URI").unwrap_or_default();
2091                grants.push(AclGrant {
2092                    grantee_type: "Group".to_string(),
2093                    grantee_id: None,
2094                    grantee_display_name: None,
2095                    grantee_uri: Some(uri),
2096                    permission,
2097                });
2098            } else {
2099                let id = extract_xml_value(grant_body, "ID").unwrap_or_default();
2100                let display =
2101                    extract_xml_value(grant_body, "DisplayName").unwrap_or_else(|| id.clone());
2102                grants.push(AclGrant {
2103                    grantee_type: "CanonicalUser".to_string(),
2104                    grantee_id: Some(id),
2105                    grantee_display_name: Some(display),
2106                    grantee_uri: None,
2107                    permission,
2108                });
2109            }
2110
2111            remaining = &after[end + 8..];
2112        } else {
2113            break;
2114        }
2115    }
2116    Ok(grants)
2117}
2118
2119// Range helpers
2120
2121pub(crate) enum RangeResult {
2122    Satisfiable { start: usize, end: usize },
2123    NotSatisfiable,
2124    Ignored,
2125}
2126
2127pub(crate) fn parse_range_header(range_str: &str, total_size: usize) -> Option<RangeResult> {
2128    let range_str = range_str.strip_prefix("bytes=")?;
2129    let (start_str, end_str) = range_str.split_once('-')?;
2130    if start_str.is_empty() {
2131        let suffix_len: usize = end_str.parse().ok()?;
2132        if suffix_len == 0 || total_size == 0 {
2133            return Some(RangeResult::NotSatisfiable);
2134        }
2135        let start = total_size.saturating_sub(suffix_len);
2136        Some(RangeResult::Satisfiable {
2137            start,
2138            end: total_size - 1,
2139        })
2140    } else {
2141        let start: usize = start_str.parse().ok()?;
2142        if start >= total_size {
2143            return Some(RangeResult::NotSatisfiable);
2144        }
2145        let end = if end_str.is_empty() {
2146            total_size - 1
2147        } else {
2148            let e: usize = end_str.parse().ok()?;
2149            if e < start {
2150                return Some(RangeResult::Ignored);
2151            }
2152            std::cmp::min(e, total_size - 1)
2153        };
2154        Some(RangeResult::Satisfiable { start, end })
2155    }
2156}
2157
2158// Helpers
2159
2160/// S3 XML response with `application/xml` content type (unlike Query protocol's `text/xml`).
2161pub(crate) fn s3_xml(status: StatusCode, body: impl Into<Bytes>) -> AwsResponse {
2162    AwsResponse {
2163        status,
2164        content_type: "application/xml".to_string(),
2165        body: body.into().into(),
2166        headers: HeaderMap::new(),
2167    }
2168}
2169
2170pub(crate) fn empty_response(status: StatusCode) -> AwsResponse {
2171    AwsResponse {
2172        status,
2173        content_type: "application/xml".to_string(),
2174        body: Bytes::new().into(),
2175        headers: HeaderMap::new(),
2176    }
2177}
2178
2179/// Returns true when the object is stored in a "cold" storage class (GLACIER, DEEP_ARCHIVE)
2180/// and has NOT been restored (or restore is still in progress).
2181pub(crate) fn is_frozen(obj: &S3Object) -> bool {
2182    matches!(obj.storage_class.as_str(), "GLACIER" | "DEEP_ARCHIVE")
2183        && obj.restore_ongoing != Some(false)
2184}
2185
2186pub(crate) fn no_such_bucket(bucket: &str) -> AwsServiceError {
2187    AwsServiceError::aws_error_with_fields(
2188        StatusCode::NOT_FOUND,
2189        "NoSuchBucket",
2190        "The specified bucket does not exist",
2191        vec![("BucketName".to_string(), bucket.to_string())],
2192    )
2193}
2194
2195pub(crate) fn no_such_key(key: &str) -> AwsServiceError {
2196    AwsServiceError::aws_error_with_fields(
2197        StatusCode::NOT_FOUND,
2198        "NoSuchKey",
2199        "The specified key does not exist.",
2200        vec![("Key".to_string(), key.to_string())],
2201    )
2202}
2203
2204pub(crate) fn no_such_upload(upload_id: &str) -> AwsServiceError {
2205    AwsServiceError::aws_error_with_fields(
2206        StatusCode::NOT_FOUND,
2207        "NoSuchUpload",
2208        "The specified upload does not exist. The upload ID may be invalid, \
2209         or the upload may have been aborted or completed.",
2210        vec![("UploadId".to_string(), upload_id.to_string())],
2211    )
2212}
2213
2214pub(crate) fn no_such_key_with_detail(key: &str) -> AwsServiceError {
2215    AwsServiceError::aws_error_with_fields(
2216        StatusCode::NOT_FOUND,
2217        "NoSuchKey",
2218        "The specified key does not exist.",
2219        vec![("Key".to_string(), key.to_string())],
2220    )
2221}
2222
2223pub(crate) fn compute_md5(data: &[u8]) -> String {
2224    let digest = Md5::digest(data);
2225    format!("{:x}", digest)
2226}
2227
2228pub(crate) fn compute_checksum(algorithm: &str, data: &[u8]) -> String {
2229    let raw = compute_checksum_raw(algorithm, data);
2230    if raw.is_empty() {
2231        String::new()
2232    } else {
2233        BASE64.encode(raw)
2234    }
2235}
2236
2237/// Compute the raw (un-base64'd) checksum digest bytes for `data`.
2238///
2239/// Returns the network-order digest bytes so callers can either base64
2240/// them ([`compute_checksum`]) or concatenate them when computing an S3
2241/// multipart COMPOSITE checksum (which hashes the concatenation of each
2242/// part's raw digest, then base64s the result and appends `-N`).
2243pub(crate) fn compute_checksum_raw(algorithm: &str, data: &[u8]) -> Vec<u8> {
2244    match algorithm {
2245        "CRC32" => crc32fast::hash(data).to_be_bytes().to_vec(),
2246        // CRC32C and CRC64NVME use the same crates as the streaming path so
2247        // CopyObject / CompleteMultipartUpload produce identical results to
2248        // a streaming PutObject (1.7); the COMPOSITE multipart checksum
2249        // concatenates these raw digests before hashing (1.18).
2250        "CRC32C" => crc32c::crc32c(data).to_be_bytes().to_vec(),
2251        "CRC64NVME" => {
2252            let mut hasher = crc64fast_nvme::Digest::new();
2253            hasher.write(data);
2254            hasher.sum64().to_be_bytes().to_vec()
2255        }
2256        "SHA1" => {
2257            use sha1::Digest as _;
2258            sha1::Sha1::digest(data).to_vec()
2259        }
2260        "SHA256" => {
2261            use sha2::Digest as _;
2262            sha2::Sha256::digest(data).to_vec()
2263        }
2264        _ => Vec::new(),
2265    }
2266}
2267
2268/// Compute an S3 multipart COMPOSITE additional checksum from each
2269/// part's raw digest bytes (in part-number order). AWS defines this as
2270/// `base64(HASH(concat(raw_part_digests)))-N` where HASH is the chosen
2271/// algorithm and N is the part count. Returns `None` for an unsupported
2272/// algorithm or empty part list.
2273pub(crate) fn compute_composite_checksum(
2274    algorithm: &str,
2275    part_raw_digests: &[Vec<u8>],
2276) -> Option<String> {
2277    if part_raw_digests.is_empty() {
2278        return None;
2279    }
2280    let mut concat = Vec::new();
2281    for d in part_raw_digests {
2282        concat.extend_from_slice(d);
2283    }
2284    let digest = compute_checksum_raw(algorithm, &concat);
2285    if digest.is_empty() {
2286        return None;
2287    }
2288    Some(format!(
2289        "{}-{}",
2290        BASE64.encode(digest),
2291        part_raw_digests.len()
2292    ))
2293}
2294
2295/// Streaming variant of [`compute_checksum`] for spool files. Reads
2296/// the file in 1 MiB chunks and feeds each chunk into the hasher so a
2297/// 1 GiB upload computes its CRC32 / SHA-1 / SHA-256 in constant
2298/// memory rather than via `tokio::fs::read` (which would allocate the
2299/// whole file as one buffer).
2300pub(crate) async fn compute_checksum_streaming(
2301    algorithm: &str,
2302    path: &std::path::Path,
2303) -> Result<String, std::io::Error> {
2304    use tokio::io::AsyncReadExt;
2305    let mut file = tokio::fs::File::open(path).await?;
2306    let mut buf = vec![0u8; 1024 * 1024];
2307    match algorithm {
2308        "CRC32" => {
2309            let mut hasher = crc32fast::Hasher::new();
2310            loop {
2311                let n = file.read(&mut buf).await?;
2312                if n == 0 {
2313                    break;
2314                }
2315                hasher.update(&buf[..n]);
2316            }
2317            Ok(BASE64.encode(hasher.finalize().to_be_bytes()))
2318        }
2319        "CRC32C" => {
2320            let mut crc: u32 = 0;
2321            loop {
2322                let n = file.read(&mut buf).await?;
2323                if n == 0 {
2324                    break;
2325                }
2326                crc = crc32c::crc32c_append(crc, &buf[..n]);
2327            }
2328            Ok(BASE64.encode(crc.to_be_bytes()))
2329        }
2330        "CRC64NVME" => {
2331            let mut hasher = crc64fast_nvme::Digest::new();
2332            loop {
2333                let n = file.read(&mut buf).await?;
2334                if n == 0 {
2335                    break;
2336                }
2337                hasher.write(&buf[..n]);
2338            }
2339            Ok(BASE64.encode(hasher.sum64().to_be_bytes()))
2340        }
2341        "SHA1" => {
2342            use sha1::Digest as _;
2343            let mut hasher = sha1::Sha1::new();
2344            loop {
2345                let n = file.read(&mut buf).await?;
2346                if n == 0 {
2347                    break;
2348                }
2349                hasher.update(&buf[..n]);
2350            }
2351            Ok(BASE64.encode(hasher.finalize()))
2352        }
2353        "SHA256" => {
2354            use sha2::Digest as _;
2355            let mut hasher = sha2::Sha256::new();
2356            loop {
2357                let n = file.read(&mut buf).await?;
2358                if n == 0 {
2359                    break;
2360                }
2361                hasher.update(&buf[..n]);
2362            }
2363            Ok(BASE64.encode(hasher.finalize()))
2364        }
2365        _ => Ok(String::new()),
2366    }
2367}
2368
2369pub(crate) fn url_encode_s3_key(s: &str) -> String {
2370    let mut out = String::with_capacity(s.len() * 2);
2371    for byte in s.bytes() {
2372        match byte {
2373            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
2374                out.push(byte as char);
2375            }
2376            _ => {
2377                out.push_str(&format!("%{:02X}", byte));
2378            }
2379        }
2380    }
2381    out
2382}
2383
2384pub(crate) use fakecloud_aws::xml::xml_escape;
2385
2386pub(crate) fn extract_user_metadata(
2387    headers: &HeaderMap,
2388) -> std::collections::BTreeMap<String, String> {
2389    let mut meta = std::collections::BTreeMap::new();
2390    for (name, value) in headers {
2391        if let Some(key) = name.as_str().strip_prefix("x-amz-meta-") {
2392            if let Ok(v) = value.to_str() {
2393                meta.insert(key.to_string(), v.to_string());
2394            }
2395        }
2396    }
2397    meta
2398}
2399
2400pub(crate) fn is_valid_storage_class(class: &str) -> bool {
2401    matches!(
2402        class,
2403        "STANDARD"
2404            | "REDUCED_REDUNDANCY"
2405            | "STANDARD_IA"
2406            | "ONEZONE_IA"
2407            | "INTELLIGENT_TIERING"
2408            | "GLACIER"
2409            | "DEEP_ARCHIVE"
2410            | "GLACIER_IR"
2411            | "OUTPOSTS"
2412            | "SNOW"
2413            | "EXPRESS_ONEZONE"
2414    )
2415}
2416
2417pub(crate) fn is_valid_bucket_name(name: &str) -> bool {
2418    // General-purpose bucket naming rules (AWS S3):
2419    // https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
2420    if name.len() < 3 || name.len() > 63 {
2421        return false;
2422    }
2423    // Only lowercase letters, digits, hyphens, and dots. Underscores are NOT
2424    // permitted for general-purpose buckets.
2425    if !name
2426        .bytes()
2427        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.')
2428    {
2429        return false;
2430    }
2431    // Must start and end with a letter or number.
2432    let bytes = name.as_bytes();
2433    if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
2434        return false;
2435    }
2436    // Must not contain two adjacent periods.
2437    if name.contains("..") {
2438        return false;
2439    }
2440    // Must not be formatted as an IPv4 address (e.g. 192.168.5.4).
2441    if is_ipv4_format(name) {
2442        return false;
2443    }
2444    // Reserved prefixes / suffixes.
2445    if name.starts_with("xn--")
2446        || name.starts_with("sthree-")
2447        || name.starts_with("amzn-s3-demo-")
2448        || name.ends_with("-s3alias")
2449        || name.ends_with("--ol-s3")
2450        || name.ends_with(".mrap")
2451    {
2452        return false;
2453    }
2454    true
2455}
2456
2457/// True when `name` is four dot-separated decimal octets (0-255), i.e. looks
2458/// like an IPv4 address, which S3 forbids as a bucket name.
2459fn is_ipv4_format(name: &str) -> bool {
2460    let parts: Vec<&str> = name.split('.').collect();
2461    parts.len() == 4
2462        && parts.iter().all(|p| {
2463            !p.is_empty()
2464                && p.len() <= 3
2465                && p.bytes().all(|b| b.is_ascii_digit())
2466                && p.parse::<u8>().is_ok()
2467        })
2468}
2469
2470pub(crate) fn is_valid_region(region: &str) -> bool {
2471    // Basic validation: region should match pattern like us-east-1, eu-west-2, etc.
2472    let valid_regions = [
2473        "us-east-1",
2474        "us-east-2",
2475        "us-west-1",
2476        "us-west-2",
2477        "af-south-1",
2478        "ap-east-1",
2479        "ap-south-1",
2480        "ap-south-2",
2481        "ap-southeast-1",
2482        "ap-southeast-2",
2483        "ap-southeast-3",
2484        "ap-southeast-4",
2485        "ap-northeast-1",
2486        "ap-northeast-2",
2487        "ap-northeast-3",
2488        "ca-central-1",
2489        "ca-west-1",
2490        "eu-central-1",
2491        "eu-central-2",
2492        "eu-west-1",
2493        "eu-west-2",
2494        "eu-west-3",
2495        "eu-south-1",
2496        "eu-south-2",
2497        "eu-north-1",
2498        "il-central-1",
2499        "me-south-1",
2500        "me-central-1",
2501        "sa-east-1",
2502        "cn-north-1",
2503        "cn-northwest-1",
2504        "us-gov-east-1",
2505        "us-gov-east-2",
2506        "us-gov-west-1",
2507        "us-iso-east-1",
2508        "us-iso-west-1",
2509        "us-isob-east-1",
2510        "us-isof-south-1",
2511    ];
2512    valid_regions.contains(&region)
2513}
2514
2515pub(crate) fn resolve_object<'a>(
2516    b: &'a S3Bucket,
2517    key: &str,
2518    version_id: Option<&String>,
2519) -> Result<&'a S3Object, AwsServiceError> {
2520    if let Some(vid) = version_id {
2521        // "null" version ID refers to an object with no version_id (pre-versioning)
2522        if vid == "null" {
2523            // Check versions for a pre-versioning object (version_id == None or Some("null"))
2524            if let Some(versions) = b.object_versions.get(key) {
2525                if let Some(obj) = versions
2526                    .iter()
2527                    .find(|o| o.version_id.is_none() || o.version_id.as_deref() == Some("null"))
2528                {
2529                    return Ok(obj);
2530                }
2531            }
2532            // Also check current object if it has no version_id
2533            if let Some(obj) = b.objects.get(key) {
2534                if obj.version_id.is_none() || obj.version_id.as_deref() == Some("null") {
2535                    return Ok(obj);
2536                }
2537            }
2538        } else {
2539            // When a specific versionId is requested, check versions first
2540            if let Some(versions) = b.object_versions.get(key) {
2541                if let Some(obj) = versions
2542                    .iter()
2543                    .find(|o| o.version_id.as_deref() == Some(vid.as_str()))
2544                {
2545                    return Ok(obj);
2546                }
2547            }
2548            // Also check current object
2549            if let Some(obj) = b.objects.get(key) {
2550                if obj.version_id.as_deref() == Some(vid.as_str()) {
2551                    return Ok(obj);
2552                }
2553            }
2554        }
2555        // For versioned buckets, return NoSuchVersion; for non-versioned, return 400
2556        if b.versioning.is_some() {
2557            Err(AwsServiceError::aws_error_with_fields(
2558                StatusCode::NOT_FOUND,
2559                "NoSuchVersion",
2560                "The specified version does not exist.",
2561                vec![
2562                    ("Key".to_string(), key.to_string()),
2563                    ("VersionId".to_string(), vid.to_string()),
2564                ],
2565            ))
2566        } else {
2567            Err(AwsServiceError::aws_error(
2568                StatusCode::BAD_REQUEST,
2569                "InvalidArgument",
2570                "Invalid version id specified",
2571            ))
2572        }
2573    } else {
2574        b.objects.get(key).ok_or_else(|| no_such_key(key))
2575    }
2576}
2577
2578pub(crate) fn make_delete_marker(key: &str, dm_id: &str) -> S3Object {
2579    S3Object {
2580        key: key.to_string(),
2581        last_modified: Utc::now(),
2582        storage_class: "STANDARD".to_string(),
2583        version_id: Some(dm_id.to_string()),
2584        is_delete_marker: true,
2585        ..Default::default()
2586    }
2587}
2588
2589/// Represents an object to delete in a batch delete request.
2590pub(crate) struct DeleteObjectEntry {
2591    key: String,
2592    version_id: Option<String>,
2593}
2594
2595pub(crate) fn parse_delete_objects_xml(xml: &str) -> Vec<DeleteObjectEntry> {
2596    let mut entries = Vec::new();
2597    let mut remaining = xml;
2598    while let Some(obj_start) = remaining.find("<Object>") {
2599        let after = &remaining[obj_start + 8..];
2600        if let Some(obj_end) = after.find("</Object>") {
2601            let obj_body = &after[..obj_end];
2602            let key = extract_xml_value(obj_body, "Key");
2603            let version_id = extract_xml_value(obj_body, "VersionId");
2604            if let Some(k) = key {
2605                entries.push(DeleteObjectEntry { key: k, version_id });
2606            }
2607            remaining = &after[obj_end + 9..];
2608        } else {
2609            break;
2610        }
2611    }
2612    entries
2613}
2614
2615/// Returns true when the request body's top-level <Quiet> element is "true".
2616/// AWS docs: when Quiet=true, only failed deletes are emitted.
2617pub(crate) fn parse_delete_objects_quiet(xml: &str) -> bool {
2618    extract_xml_value(xml, "Quiet")
2619        .map(|v| v.eq_ignore_ascii_case("true"))
2620        .unwrap_or(false)
2621}
2622
2623/// Minimal XML parser for `<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag>...`.
2624/// Returns a Vec to preserve insertion order and detect duplicates.
2625pub(crate) fn parse_tagging_xml(xml: &str) -> Vec<(String, String)> {
2626    let mut tags = Vec::new();
2627    let mut remaining = xml;
2628    while let Some(tag_start) = remaining.find("<Tag>") {
2629        let after = &remaining[tag_start + 5..];
2630        if let Some(tag_end) = after.find("</Tag>") {
2631            let tag_body = &after[..tag_end];
2632            let key = extract_xml_value(tag_body, "Key");
2633            let value = extract_xml_value(tag_body, "Value");
2634            if let (Some(k), Some(v)) = (key, value) {
2635                tags.push((k, v));
2636            }
2637            remaining = &after[tag_end + 6..];
2638        } else {
2639            break;
2640        }
2641    }
2642    tags
2643}
2644
2645pub(crate) fn validate_tags(tags: &[(String, String)]) -> Result<(), AwsServiceError> {
2646    // Check for duplicate keys
2647    let mut seen = std::collections::HashSet::new();
2648    for (k, _) in tags {
2649        if !seen.insert(k.as_str()) {
2650            return Err(AwsServiceError::aws_error(
2651                StatusCode::BAD_REQUEST,
2652                "InvalidTag",
2653                "Cannot provide multiple Tags with the same key",
2654            ));
2655        }
2656        // Check for aws: prefix
2657        if k.starts_with("aws:") {
2658            return Err(AwsServiceError::aws_error(
2659                StatusCode::BAD_REQUEST,
2660                "InvalidTag",
2661                "System tags cannot be added/updated by requester",
2662            ));
2663        }
2664    }
2665    Ok(())
2666}
2667
2668pub(crate) fn extract_xml_value(xml: &str, tag: &str) -> Option<String> {
2669    // Handle self-closing tags like <Value /> or <Value/>
2670    let self_closing1 = format!("<{tag} />");
2671    let self_closing2 = format!("<{tag}/>");
2672    if xml.contains(&self_closing1) || xml.contains(&self_closing2) {
2673        // Check if the self-closing tag appears before any open+close pair
2674        let self_pos = xml
2675            .find(&self_closing1)
2676            .or_else(|| xml.find(&self_closing2));
2677        let open = format!("<{tag}>");
2678        let open_pos = xml.find(&open);
2679        match (self_pos, open_pos) {
2680            (Some(sp), Some(op)) if sp < op => return Some(String::new()),
2681            (Some(_), None) => return Some(String::new()),
2682            _ => {}
2683        }
2684    }
2685
2686    let open = format!("<{tag}>");
2687    let close = format!("</{tag}>");
2688    let start = xml.find(&open)? + open.len();
2689    // Search for the closing tag AFTER the opening one so a malformed body
2690    // (closing tag before opening) can't make end < start and panic the
2691    // slice (bug-audit 2026-05-28, 2.2).
2692    let end = xml[start..].find(&close)? + start;
2693    // Decode XML entities in the text node. S3 request bodies arrive
2694    // XML-encoded — every SDK escapes `&`, `<`, `>` (and sometimes `"`/`'`
2695    // plus numeric refs) — so a key/value like `a&b` is on the wire as
2696    // `a&amp;b`. Without decoding, an object keyed `a&b` (the key arrives
2697    // URL-decoded from the request path) is never matched by a
2698    // DeleteObjects/PutObjectTagging body, and a tag value round-trips with
2699    // an extra layer of escaping on every GET.
2700    Some(decode_xml_entities(&xml[start..end]))
2701}
2702
2703/// Decode the standard XML entities in a text node value using a single
2704/// left-to-right pass. A chain of `str::replace` calls would double-decode
2705/// (e.g. `&amp;lt;` — the escaped literal text `&lt;` — must decode to
2706/// `&lt;`, not to `<`), so the scan resolves each `&…;` exactly once.
2707/// Handles the named entities (`amp`, `lt`, `gt`, `quot`, `apos`) and both
2708/// decimal (`&#38;`) and hex (`&#x26;`) numeric character references; an
2709/// unrecognized `&…` sequence is emitted verbatim, matching how lenient XML
2710/// readers treat a bare ampersand.
2711pub(crate) fn decode_xml_entities(s: &str) -> String {
2712    if !s.contains('&') {
2713        return s.to_string();
2714    }
2715    let mut out = String::with_capacity(s.len());
2716    let bytes = s.as_bytes();
2717    let mut i = 0;
2718    while i < s.len() {
2719        if bytes[i] == b'&' {
2720            // Bound the entity name so a stray '&' followed by a distant ';'
2721            // doesn't swallow a long span of text.
2722            if let Some(rel) = s[i + 1..].find(';') {
2723                if rel <= 10 {
2724                    let entity = &s[i + 1..i + 1 + rel];
2725                    let decoded = match entity {
2726                        "amp" => Some('&'),
2727                        "lt" => Some('<'),
2728                        "gt" => Some('>'),
2729                        "quot" => Some('"'),
2730                        "apos" => Some('\''),
2731                        _ => entity
2732                            .strip_prefix("#x")
2733                            .or_else(|| entity.strip_prefix("#X"))
2734                            .and_then(|hex| u32::from_str_radix(hex, 16).ok())
2735                            .or_else(|| {
2736                                entity.strip_prefix('#').and_then(|d| d.parse::<u32>().ok())
2737                            })
2738                            .and_then(char::from_u32),
2739                    };
2740                    if let Some(c) = decoded {
2741                        out.push(c);
2742                        i += 1 + rel + 1;
2743                        continue;
2744                    }
2745                }
2746            }
2747            out.push('&');
2748            i += 1;
2749        } else {
2750            let ch = s[i..].chars().next().unwrap();
2751            out.push(ch);
2752            i += ch.len_utf8();
2753        }
2754    }
2755    out
2756}
2757
2758/// Parse the CompleteMultipartUpload XML body into (part_number, etag) pairs.
2759/// Strip the surrounding quotes AWS clients wrap a part ETag in, so the value
2760/// matches the bare hex ETag fakecloud stores. The quote character arrives in
2761/// several encodings depending on the client's XML serializer: a literal `"`
2762/// (aws-cli/boto), the named entity `&quot;`, or the numeric character
2763/// references `&#34;` / `&#x22;` (aws-sdk-go-v2 — the terraform provider). All
2764/// must be removed, else a valid part is rejected with `InvalidPart` on
2765/// CompleteMultipartUpload. (2026-07-07: go-SDK multipart uploads of large
2766/// objects, e.g. the KinesisAnalyticsV2 tfacc app JAR, failed on this.)
2767pub(crate) fn strip_etag_quotes(s: &str) -> String {
2768    s.replace("&quot;", "")
2769        .replace("&#34;", "")
2770        .replace("&#x22;", "")
2771        .replace("&#X22;", "")
2772        .replace('"', "")
2773        .trim()
2774        .to_string()
2775}
2776
2777pub(crate) fn parse_complete_multipart_xml(xml: &str) -> Vec<(u32, String)> {
2778    let mut parts = Vec::new();
2779    let mut remaining = xml;
2780    while let Some(part_start) = remaining.find("<Part>") {
2781        let after = &remaining[part_start + 6..];
2782        if let Some(part_end) = after.find("</Part>") {
2783            let part_body = &after[..part_end];
2784            let part_num =
2785                extract_xml_value(part_body, "PartNumber").and_then(|s| s.parse::<u32>().ok());
2786            let etag = extract_xml_value(part_body, "ETag").map(|s| strip_etag_quotes(&s));
2787            if let (Some(num), Some(e)) = (part_num, etag) {
2788                parts.push((num, e));
2789            }
2790            remaining = &after[part_end + 7..];
2791        } else {
2792            break;
2793        }
2794    }
2795    parts
2796}
2797
2798pub(crate) fn parse_url_encoded_tags(s: &str) -> Vec<(String, String)> {
2799    let mut tags = Vec::new();
2800    for pair in s.split('&') {
2801        if pair.is_empty() {
2802            continue;
2803        }
2804        let (key, value) = match pair.find('=') {
2805            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
2806            None => (pair, ""),
2807        };
2808        tags.push((
2809            percent_encoding::percent_decode_str(key)
2810                .decode_utf8_lossy()
2811                .to_string(),
2812            percent_encoding::percent_decode_str(value)
2813                .decode_utf8_lossy()
2814                .to_string(),
2815        ));
2816    }
2817    tags
2818}
2819
2820/// Validate lifecycle configuration XML. Returns MalformedXML on invalid configs.
2821pub(crate) fn validate_lifecycle_xml(xml: &str) -> Result<(), AwsServiceError> {
2822    let malformed = || {
2823        AwsServiceError::aws_error(
2824            StatusCode::BAD_REQUEST,
2825            "MalformedXML",
2826            "The XML you provided was not well-formed or did not validate against our published schema",
2827        )
2828    };
2829
2830    let mut remaining = xml;
2831    while let Some(rule_start) = remaining.find("<Rule>") {
2832        let after = &remaining[rule_start + 6..];
2833        if let Some(rule_end) = after.find("</Rule>") {
2834            let rule_body = &after[..rule_end];
2835
2836            // Must have Filter or Prefix
2837            let has_filter = rule_body.contains("<Filter>")
2838                || rule_body.contains("<Filter/>")
2839                || rule_body.contains("<Filter />");
2840
2841            // Check for <Prefix> at rule level (outside of <Filter>...</Filter>)
2842            let has_prefix_outside_filter = {
2843                if !rule_body.contains("<Prefix") {
2844                    false
2845                } else if !has_filter {
2846                    true // No filter means any Prefix is at rule level
2847                } else {
2848                    // Remove the Filter block and check if Prefix remains
2849                    let mut stripped = rule_body.to_string();
2850                    // Remove <Filter>...</Filter> or self-closing variants
2851                    if let Some(fs) = stripped.find("<Filter") {
2852                        if let Some(fe) = stripped.find("</Filter>") {
2853                            stripped = format!("{}{}", &stripped[..fs], &stripped[fe + 9..]);
2854                        }
2855                    }
2856                    stripped.contains("<Prefix")
2857                }
2858            };
2859
2860            if !has_filter && !has_prefix_outside_filter {
2861                return Err(malformed());
2862            }
2863            // Can't have both Filter and rule-level Prefix
2864            if has_filter && has_prefix_outside_filter {
2865                return Err(malformed());
2866            }
2867
2868            // Expiration: if has ExpiredObjectDeleteMarker, cannot also have Days or Date
2869            // (only check within <Expiration> block)
2870            if let Some(exp_start) = rule_body.find("<Expiration>") {
2871                if let Some(exp_end) = rule_body[exp_start..].find("</Expiration>") {
2872                    let exp_body = &rule_body[exp_start..exp_start + exp_end];
2873                    if exp_body.contains("<ExpiredObjectDeleteMarker>")
2874                        && (exp_body.contains("<Days>") || exp_body.contains("<Date>"))
2875                    {
2876                        return Err(malformed());
2877                    }
2878                }
2879            }
2880
2881            // Filter validation
2882            if has_filter {
2883                if let Some(fs) = rule_body.find("<Filter>") {
2884                    if let Some(fe) = rule_body.find("</Filter>") {
2885                        // `find` locates the two tags independently, so a body
2886                        // with `</Filter>` before `<Filter>` would slice with
2887                        // begin > end and panic (dropping the connection -- a
2888                        // reachable DoS). Reject as malformed instead.
2889                        if fe < fs + 8 {
2890                            return Err(malformed());
2891                        }
2892                        let filter_body = &rule_body[fs + 8..fe];
2893                        let has_prefix_in_filter = filter_body.contains("<Prefix");
2894                        let has_tag_in_filter = filter_body.contains("<Tag>");
2895                        let has_and_in_filter = filter_body.contains("<And>");
2896                        // Can't have both Prefix and Tag without And
2897                        if has_prefix_in_filter && has_tag_in_filter && !has_and_in_filter {
2898                            return Err(malformed());
2899                        }
2900                        // Can't have Tag and And simultaneously at the Filter level
2901                        if has_tag_in_filter && has_and_in_filter {
2902                            // Check if the <Tag> is outside <And>
2903                            let and_start = filter_body.find("<And>").unwrap_or(0);
2904                            let tag_pos = filter_body.find("<Tag>").unwrap_or(0);
2905                            if tag_pos < and_start {
2906                                return Err(malformed());
2907                            }
2908                        }
2909                    }
2910                }
2911            }
2912
2913            // NoncurrentVersionTransition must have NoncurrentDays and StorageClass
2914            if rule_body.contains("<NoncurrentVersionTransition>") {
2915                let mut nvt_remaining = rule_body;
2916                while let Some(nvt_start) = nvt_remaining.find("<NoncurrentVersionTransition>") {
2917                    let nvt_after = &nvt_remaining[nvt_start + 29..];
2918                    if let Some(nvt_end) = nvt_after.find("</NoncurrentVersionTransition>") {
2919                        let nvt_body = &nvt_after[..nvt_end];
2920                        if !nvt_body.contains("<NoncurrentDays>") {
2921                            return Err(malformed());
2922                        }
2923                        if !nvt_body.contains("<StorageClass>") {
2924                            return Err(malformed());
2925                        }
2926                        nvt_remaining = &nvt_after[nvt_end + 30..];
2927                    } else {
2928                        break;
2929                    }
2930                }
2931            }
2932
2933            remaining = &after[rule_end + 7..];
2934        } else {
2935            break;
2936        }
2937    }
2938
2939    Ok(())
2940}
2941
2942/// Parsed CORS rule from bucket configuration XML.
2943pub(crate) struct CorsRule {
2944    allowed_origins: Vec<String>,
2945    allowed_methods: Vec<String>,
2946    allowed_headers: Vec<String>,
2947    expose_headers: Vec<String>,
2948    max_age_seconds: Option<u32>,
2949}
2950
2951/// Parse CORS configuration XML into rules.
2952pub(crate) fn parse_cors_config(xml: &str) -> Vec<CorsRule> {
2953    let mut rules = Vec::new();
2954    let mut remaining = xml;
2955    while let Some(start) = remaining.find("<CORSRule>") {
2956        let after = &remaining[start + 10..];
2957        if let Some(end) = after.find("</CORSRule>") {
2958            let block = &after[..end];
2959            let allowed_origins = extract_all_xml_values(block, "AllowedOrigin");
2960            let allowed_methods = extract_all_xml_values(block, "AllowedMethod");
2961            let allowed_headers = extract_all_xml_values(block, "AllowedHeader");
2962            let expose_headers = extract_all_xml_values(block, "ExposeHeader");
2963            let max_age_seconds =
2964                extract_xml_value(block, "MaxAgeSeconds").and_then(|s| s.parse().ok());
2965            rules.push(CorsRule {
2966                allowed_origins,
2967                allowed_methods,
2968                allowed_headers,
2969                expose_headers,
2970                max_age_seconds,
2971            });
2972            remaining = &after[end + 11..];
2973        } else {
2974            break;
2975        }
2976    }
2977    rules
2978}
2979
2980/// Match an origin against a CORS allowed origin pattern (supports "*" wildcard).
2981pub(crate) fn origin_matches(origin: &str, pattern: &str) -> bool {
2982    if pattern == "*" {
2983        return true;
2984    }
2985    // Simple wildcard: *.example.com
2986    if let Some(suffix) = pattern.strip_prefix('*') {
2987        return origin.ends_with(suffix);
2988    }
2989    origin == pattern
2990}
2991
2992/// Find the matching CORS rule for a given origin and method.
2993pub(crate) fn find_cors_rule<'a>(
2994    rules: &'a [CorsRule],
2995    origin: &str,
2996    method: Option<&str>,
2997) -> Option<&'a CorsRule> {
2998    rules.iter().find(|rule| {
2999        let origin_ok = rule
3000            .allowed_origins
3001            .iter()
3002            .any(|o| origin_matches(origin, o));
3003        let method_ok = match method {
3004            Some(m) => rule.allowed_methods.iter().any(|am| am == m),
3005            None => true,
3006        };
3007        origin_ok && method_ok
3008    })
3009}
3010
3011/// Check if an object is locked (retention or legal hold) and should block mutation.
3012/// Returns an error string if locked, None if allowed.
3013pub(crate) fn check_object_lock_for_overwrite(
3014    obj: &S3Object,
3015    req: &AwsRequest,
3016) -> Option<&'static str> {
3017    // Legal hold blocks overwrite
3018    if obj.lock_legal_hold.as_deref() == Some("ON") {
3019        return Some("AccessDenied");
3020    }
3021    // Retention check
3022    if let (Some(mode), Some(until)) = (&obj.lock_mode, &obj.lock_retain_until) {
3023        if *until > Utc::now() {
3024            if mode == "COMPLIANCE" {
3025                return Some("AccessDenied");
3026            }
3027            if mode == "GOVERNANCE" {
3028                let bypass = req
3029                    .headers
3030                    .get("x-amz-bypass-governance-retention")
3031                    .and_then(|v| v.to_str().ok())
3032                    .map(|s| s.eq_ignore_ascii_case("true"))
3033                    .unwrap_or(false);
3034                if !bypass {
3035                    return Some("AccessDenied");
3036                }
3037            }
3038        }
3039    }
3040    None
3041}
3042
3043#[cfg(test)]
3044mod tests;
3045
3046#[cfg(test)]
3047mod s3_detect_action_tests {
3048    //! Auth-mapping regressions for bug-hunt 2026-06-15 §5.3: ops that
3049    //! were unmapped (-> enforcement skipped) or mapped to the wrong
3050    //! action (-> the policy targeting them never applied).
3051    use super::s3_detect_action;
3052    use std::collections::HashMap;
3053
3054    fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3055        pairs
3056            .iter()
3057            .map(|(k, v)| (k.to_string(), v.to_string()))
3058            .collect()
3059    }
3060
3061    #[test]
3062    fn select_object_content_maps_to_get_object() {
3063        // POST /bucket/key?select&select-type=2 reads object data.
3064        let action = s3_detect_action(
3065            "POST",
3066            Some("bucket"),
3067            Some("key"),
3068            &q(&[("select", ""), ("select-type", "2")]),
3069        );
3070        assert_eq!(action, Some("GetObject"));
3071    }
3072
3073    #[test]
3074    fn write_get_object_response_is_detected() {
3075        // POST /WriteGetObjectResponse (no key) — Object Lambda data plane.
3076        let action = s3_detect_action("POST", Some("WriteGetObjectResponse"), None, &q(&[]));
3077        assert_eq!(action, Some("WriteGetObjectResponse"));
3078    }
3079
3080    #[test]
3081    fn list_directory_buckets_is_distinct_from_list_buckets() {
3082        // GET /?x-id=ListDirectoryBuckets -> s3express action, not ListBuckets.
3083        let dir = s3_detect_action("GET", None, None, &q(&[("x-id", "ListDirectoryBuckets")]));
3084        assert_eq!(dir, Some("ListAllMyDirectoryBuckets"));
3085        // Plain GET / is still ListBuckets.
3086        let plain = s3_detect_action("GET", None, None, &q(&[]));
3087        assert_eq!(plain, Some("ListBuckets"));
3088    }
3089
3090    #[test]
3091    fn rename_object_is_detected_with_key() {
3092        // PUT /bucket/newkey?renameObject carries a key — previously fell
3093        // through to PutObject so s3:RenameObject denies were ineffective.
3094        let action = s3_detect_action(
3095            "PUT",
3096            Some("bucket"),
3097            Some("newkey"),
3098            &q(&[("renameObject", "")]),
3099        );
3100        assert_eq!(action, Some("RenameObject"));
3101    }
3102
3103    #[test]
3104    fn update_object_encryption_maps_to_put_object() {
3105        // PUT /bucket/key?encryption changes object SSE; AWS gates this on
3106        // s3:PutObject (no distinct public action). Deliberate, not a
3107        // silent PutObject fall-through.
3108        let action = s3_detect_action(
3109            "PUT",
3110            Some("bucket"),
3111            Some("key"),
3112            &q(&[("encryption", "")]),
3113        );
3114        assert_eq!(action, Some("PutObject"));
3115    }
3116
3117    #[test]
3118    fn create_session_not_misdetected_as_list_objects() {
3119        // GET /bucket?session — previously fell through to ListObjects.
3120        let action = s3_detect_action("GET", Some("bucket"), None, &q(&[("session", "")]));
3121        assert_eq!(action, Some("CreateSession"));
3122    }
3123
3124    #[test]
3125    fn get_bucket_abac_not_misdetected_as_list_objects() {
3126        // GET /bucket?abac — previously fell through to ListObjects.
3127        let action = s3_detect_action("GET", Some("bucket"), None, &q(&[("abac", "")]));
3128        assert_eq!(action, Some("GetBucketAbacConfiguration"));
3129    }
3130
3131    #[test]
3132    fn put_bucket_abac_still_detected() {
3133        let action = s3_detect_action("PUT", Some("bucket"), None, &q(&[("abac", "")]));
3134        assert_eq!(action, Some("PutBucketAbacConfiguration"));
3135    }
3136}
3137
3138#[cfg(test)]
3139mod s3_iam_service_prefix_tests {
3140    //! §5.3: CreateSession and WriteGetObjectResponse are authorized under
3141    //! sibling IAM service prefixes (s3express / s3-object-lambda), not s3.
3142    use super::*;
3143    use parking_lot::RwLock;
3144    use std::sync::Arc;
3145
3146    fn make_service() -> S3Service {
3147        let state: SharedS3State = Arc::new(RwLock::new(
3148            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3149        ));
3150        S3Service::new(state, Arc::new(DeliveryBus::new()))
3151    }
3152
3153    fn req(method: Method, path: &str, query: &[(&str, &str)]) -> AwsRequest {
3154        let path_segments: Vec<String> = path
3155            .split('/')
3156            .filter(|s| !s.is_empty())
3157            .map(|s| s.to_string())
3158            .collect();
3159        AwsRequest {
3160            service: "s3".to_string(),
3161            action: String::new(),
3162            region: "us-east-1".to_string(),
3163            account_id: "123456789012".to_string(),
3164            request_id: "rid".to_string(),
3165            headers: http::HeaderMap::new(),
3166            query_params: query
3167                .iter()
3168                .map(|(k, v)| (k.to_string(), v.to_string()))
3169                .collect(),
3170            body: bytes::Bytes::new(),
3171            body_stream: parking_lot::Mutex::new(None),
3172            path_segments,
3173            raw_path: path.to_string(),
3174            raw_query: String::new(),
3175            method,
3176            is_query_protocol: false,
3177            access_key_id: None,
3178            principal: None,
3179        }
3180    }
3181
3182    #[test]
3183    fn create_session_uses_s3express_prefix() {
3184        let svc = make_service();
3185        let action = svc
3186            .iam_action_for(&req(Method::GET, "/mybucket", &[("session", "")]))
3187            .expect("must be mapped");
3188        assert_eq!(action.service, "s3express");
3189        assert_eq!(action.action, "CreateSession");
3190        assert_eq!(action.action_string(), "s3express:CreateSession");
3191    }
3192
3193    #[test]
3194    fn write_get_object_response_uses_object_lambda_prefix() {
3195        let svc = make_service();
3196        let action = svc
3197            .iam_action_for(&req(Method::POST, "/WriteGetObjectResponse", &[]))
3198            .expect("must be mapped");
3199        assert_eq!(action.service, "s3-object-lambda");
3200        assert_eq!(action.action, "WriteGetObjectResponse");
3201        assert_eq!(
3202            action.action_string(),
3203            "s3-object-lambda:WriteGetObjectResponse"
3204        );
3205    }
3206
3207    #[test]
3208    fn get_object_torrent_maps_to_its_own_action() {
3209        // GET object with `?torrent` must authorize under s3:GetObjectTorrent,
3210        // not s3:GetObject (bug-audit 2026-06-20, 5.3).
3211        let svc = make_service();
3212        let action = svc
3213            .iam_action_for(&req(Method::GET, "/mybucket/key.txt", &[("torrent", "")]))
3214            .expect("must be mapped");
3215        assert_eq!(action.action, "GetObjectTorrent");
3216        // A plain GET still maps to GetObject.
3217        let plain = svc
3218            .iam_action_for(&req(Method::GET, "/mybucket/key.txt", &[]))
3219            .expect("must be mapped");
3220        assert_eq!(plain.action, "GetObject");
3221    }
3222}
3223
3224#[cfg(test)]
3225mod extract_xml_value_tests {
3226    use super::{decode_xml_entities, extract_xml_value};
3227
3228    #[test]
3229    fn returns_inner_value() {
3230        assert_eq!(
3231            extract_xml_value("<Root><Key>value</Key></Root>", "Key"),
3232            Some("value".to_string())
3233        );
3234    }
3235
3236    // The wire form of a key `a&b` is `<Key>a&amp;b</Key>`; the parsed value
3237    // must decode back to `a&b` so it matches the URL-decoded key that
3238    // arrives via the request path (otherwise DeleteObjects/Tagging silently
3239    // miss the object).
3240    #[test]
3241    fn decodes_named_and_numeric_entities() {
3242        assert_eq!(
3243            extract_xml_value("<Key>a&amp;b</Key>", "Key"),
3244            Some("a&b".to_string())
3245        );
3246        assert_eq!(
3247            extract_xml_value("<Key>&lt;x&gt; &quot;y&quot; &apos;z&apos;</Key>", "Key"),
3248            Some("<x> \"y\" 'z'".to_string())
3249        );
3250        assert_eq!(
3251            extract_xml_value("<Key>a&#38;b&#x26;c</Key>", "Key"),
3252            Some("a&b&c".to_string())
3253        );
3254    }
3255
3256    // A single left-to-right pass must resolve each entity exactly once:
3257    // `&amp;lt;` is the escaped literal text `&lt;`, so it decodes to `&lt;`,
3258    // NOT to `<` (which a chain of `str::replace` calls would wrongly produce).
3259    #[test]
3260    fn does_not_double_decode() {
3261        assert_eq!(decode_xml_entities("&amp;lt;"), "&lt;");
3262        assert_eq!(decode_xml_entities("&amp;amp;"), "&amp;");
3263    }
3264
3265    #[test]
3266    fn leaves_bare_ampersand_and_unknown_entities_verbatim() {
3267        assert_eq!(decode_xml_entities("a & b"), "a & b");
3268        assert_eq!(decode_xml_entities("50% &bogus; done"), "50% &bogus; done");
3269        assert_eq!(decode_xml_entities("no entities here"), "no entities here");
3270    }
3271
3272    #[test]
3273    fn missing_tag_is_none() {
3274        assert_eq!(extract_xml_value("<Root></Root>", "Key"), None);
3275    }
3276
3277    // bug-audit 2026-05-28, 2.2: a closing tag before the opening one used to
3278    // slice with end < start and panic; must return None instead.
3279    #[test]
3280    fn close_before_open_does_not_panic() {
3281        assert_eq!(extract_xml_value("</Key>oops<Key>value", "Key"), None);
3282    }
3283
3284    #[test]
3285    fn open_without_close_is_none() {
3286        assert_eq!(extract_xml_value("<Key>value", "Key"), None);
3287    }
3288}
3289
3290#[cfg(test)]
3291mod compute_checksum_tests {
3292    use super::{compute_checksum, compute_checksum_streaming};
3293
3294    // bug-audit 2026-06-15, 1.7: CopyObject / CompleteMultipartUpload routed
3295    // through the non-streaming compute_checksum, whose `_ =>` arm returned an
3296    // empty string for CRC32C / CRC64NVME -> GetObjectAttributes reported an
3297    // empty checksum and integrity checks silently broke.
3298    #[test]
3299    fn crc32c_is_non_empty_and_matches_known_vector() {
3300        // CRC32C of "123456789" is 0xE3069283; base64 of those 4 BE bytes.
3301        let out = compute_checksum("CRC32C", b"123456789");
3302        assert!(!out.is_empty());
3303        let bytes = base64::engine::general_purpose::STANDARD
3304            .decode(&out)
3305            .unwrap();
3306        assert_eq!(bytes, 0xE306_9283u32.to_be_bytes());
3307    }
3308
3309    #[test]
3310    fn crc64nvme_is_non_empty() {
3311        let out = compute_checksum("CRC64NVME", b"hello world");
3312        assert!(!out.is_empty());
3313        // 8 BE bytes of a u64.
3314        let bytes = base64::engine::general_purpose::STANDARD
3315            .decode(&out)
3316            .unwrap();
3317        assert_eq!(bytes.len(), 8);
3318    }
3319
3320    #[tokio::test]
3321    async fn non_streaming_matches_streaming_for_all_algorithms() {
3322        let data = b"the quick brown fox jumps over the lazy dog".repeat(100);
3323        let tmp = tempfile::NamedTempFile::new().unwrap();
3324        tokio::fs::write(tmp.path(), &data).await.unwrap();
3325
3326        for algo in ["CRC32", "CRC32C", "CRC64NVME", "SHA1", "SHA256"] {
3327            let direct = compute_checksum(algo, &data);
3328            let streamed = compute_checksum_streaming(algo, tmp.path()).await.unwrap();
3329            assert!(!direct.is_empty(), "{algo} direct empty");
3330            assert_eq!(direct, streamed, "{algo} direct != streaming");
3331        }
3332    }
3333
3334    use base64::Engine as _;
3335}
3336
3337#[cfg(test)]
3338mod complete_multipart_parse_tests {
3339    use super::parse_complete_multipart_xml;
3340
3341    // 2026-07-07: aws-sdk-go-v2 (the terraform AWS provider) serializes the
3342    // part ETag with the quote char as the numeric reference `&#34;`, e.g.
3343    // <ETag>&#34;<hex>&#34;</ETag>. The parser must decode that to the bare hex
3344    // so it matches the stored part ETag; otherwise every large go-SDK
3345    // multipart upload fails CompleteMultipartUpload with InvalidPart.
3346    #[test]
3347    fn parses_go_sdk_numeric_quote_entity_etag() {
3348        let xml = "<CompleteMultipartUpload>\
3349            <Part><PartNumber>1</PartNumber><ETag>&#34;100bffa249c1cbde1a66242835cdab03&#34;</ETag></Part>\
3350            <Part><PartNumber>2</PartNumber><ETag>&#x22;5df1aff8ceab081b20d677b2d4f7eab1&#x22;</ETag></Part>\
3351            </CompleteMultipartUpload>";
3352        let parts = parse_complete_multipart_xml(xml);
3353        assert_eq!(
3354            parts,
3355            vec![
3356                (1, "100bffa249c1cbde1a66242835cdab03".to_string()),
3357                (2, "5df1aff8ceab081b20d677b2d4f7eab1".to_string()),
3358            ]
3359        );
3360    }
3361
3362    // aws-cli / boto send the quote as a literal `"` or the named entity
3363    // `&quot;` — both must still parse to the same bare hex.
3364    #[test]
3365    fn parses_cli_literal_and_named_quote_etag() {
3366        let xml = "<CompleteMultipartUpload>\
3367            <Part><PartNumber>1</PartNumber><ETag>\"abc123\"</ETag></Part>\
3368            <Part><PartNumber>2</PartNumber><ETag>&quot;def456&quot;</ETag></Part>\
3369            </CompleteMultipartUpload>";
3370        let parts = parse_complete_multipart_xml(xml);
3371        assert_eq!(
3372            parts,
3373            vec![(1, "abc123".to_string()), (2, "def456".to_string())]
3374        );
3375    }
3376}