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;
24mod config;
25mod lock;
26mod multipart;
27mod 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}
56
57/// Map a [`StoreError`] from the persistence layer to a 500 InternalError
58/// response. Invoked at every mutation site when the write-through persistence
59/// call fails: the in-memory mutation has already happened, but we surface the
60/// failure to the client so they know to retry (and so logs/metrics flag it).
61pub(crate) fn persistence_error(err: StoreError) -> AwsServiceError {
62    AwsServiceError::aws_error(
63        StatusCode::INTERNAL_SERVER_ERROR,
64        "InternalError",
65        format!("persistence store error: {err}"),
66    )
67}
68
69/// Convert a filesystem IO error from a disk-backed body read into an
70/// InternalError response.
71pub(crate) fn io_to_aws(err: std::io::Error) -> AwsServiceError {
72    AwsServiceError::aws_error(
73        StatusCode::INTERNAL_SERVER_ERROR,
74        "InternalError",
75        format!("failed to read object body from disk: {err}"),
76    )
77}
78
79impl S3Service {
80    pub fn new(state: SharedS3State, delivery: Arc<DeliveryBus>) -> Self {
81        Self::with_store(state, delivery, Arc::new(MemoryS3Store::new()))
82    }
83
84    pub fn with_store(
85        state: SharedS3State,
86        delivery: Arc<DeliveryBus>,
87        store: Arc<dyn S3Store>,
88    ) -> Self {
89        Self {
90            state,
91            delivery,
92            kms_state: None,
93            kms_hook: None,
94            store,
95        }
96    }
97
98    pub fn with_kms(mut self, kms_state: SharedKmsState) -> Self {
99        self.kms_state = Some(kms_state);
100        self
101    }
102
103    pub fn with_kms_hook(mut self, hook: Arc<dyn fakecloud_core::delivery::KmsHook>) -> Self {
104        self.kms_hook = Some(hook);
105        self
106    }
107
108    /// Encrypt object body bytes for SSE-KMS storage. Returns ciphertext as
109    /// raw bytes (a UTF-8 fakecloud-kms envelope) on success.
110    ///
111    /// Fail-closed: if the KMS hook reports an error (key denied, key not
112    /// found, etc.), this returns `Err` so PutObject aborts with a 500
113    /// rather than silently storing plaintext. AWS S3 has the same
114    /// behavior — `KMS.NotFoundException` and friends surface as
115    /// `AccessDenied` / `KMS.*` errors back to the caller. When no hook is
116    /// wired (legacy / in-process tests with no KMS dependency), the
117    /// plaintext is returned unchanged so existing tests keep working.
118    pub(crate) fn encrypt_object_body(
119        &self,
120        account_id: &str,
121        region: &str,
122        bucket: &str,
123        plaintext: &[u8],
124        kms_key_id: Option<&str>,
125    ) -> Result<bytes::Bytes, AwsServiceError> {
126        let Some(hook) = &self.kms_hook else {
127            return Ok(bytes::Bytes::copy_from_slice(plaintext));
128        };
129        let key = kms_key_id.filter(|k| !k.is_empty()).unwrap_or("aws/s3");
130        let bucket_arn = Arn::s3(bucket).to_string();
131        let mut ctx = std::collections::HashMap::new();
132        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
133        match hook.encrypt(account_id, region, key, plaintext, "s3.amazonaws.com", ctx) {
134            Ok(envelope) => Ok(bytes::Bytes::from(envelope.into_bytes())),
135            Err(err) => {
136                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS encrypt failed");
137                Err(AwsServiceError::aws_error(
138                    StatusCode::INTERNAL_SERVER_ERROR,
139                    "KMS.InternalFailureException",
140                    format!("Failed to encrypt object via KMS: {err}"),
141                ))
142            }
143        }
144    }
145
146    /// Decrypt object body bytes that were stored as a fakecloud-kms
147    /// envelope. Caller is expected to gate this on
148    /// `obj.sse_algorithm == Some("aws:kms")`.
149    ///
150    /// Fail-closed: when a hook is wired and the bytes look like an
151    /// envelope but don't decrypt (key revoked, malformed ciphertext),
152    /// this returns `Err` so GetObject surfaces a 500. When no hook is
153    /// wired, or the bytes aren't UTF-8 (legacy snapshots from before
154    /// the hook landed), the bytes are returned unchanged.
155    pub(crate) fn decrypt_object_body(
156        &self,
157        account_id: &str,
158        bucket: &str,
159        ciphertext: &[u8],
160    ) -> Result<bytes::Bytes, AwsServiceError> {
161        let Some(hook) = &self.kms_hook else {
162            return Ok(bytes::Bytes::copy_from_slice(ciphertext));
163        };
164        // Stored envelope is base64 ASCII; non-UTF-8 bytes are pre-hook
165        // legacy snapshots, return as-is.
166        let envelope = match std::str::from_utf8(ciphertext) {
167            Ok(s) => s,
168            Err(_) => return Ok(bytes::Bytes::copy_from_slice(ciphertext)),
169        };
170        let bucket_arn = Arn::s3(bucket).to_string();
171        let mut ctx = std::collections::HashMap::new();
172        ctx.insert("aws:s3:arn".to_string(), bucket_arn);
173        match hook.decrypt(account_id, envelope, "s3.amazonaws.com", ctx) {
174            Ok(bytes) => Ok(bytes::Bytes::from(bytes)),
175            Err(err) => {
176                tracing::warn!(bucket = %bucket, error = %err, "SSE-KMS decrypt failed");
177                Err(AwsServiceError::aws_error(
178                    StatusCode::INTERNAL_SERVER_ERROR,
179                    "KMS.InternalFailureException",
180                    format!("Failed to decrypt object via KMS: {err}"),
181                ))
182            }
183        }
184    }
185}
186
187#[async_trait]
188impl AwsService for S3Service {
189    fn service_name(&self) -> &str {
190        "s3"
191    }
192
193    async fn handle(&self, mut req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
194        // PutObject / UploadPart enter dispatch via the streaming path
195        // with `body = Bytes::new()` and the raw HTTP body available on
196        // `req.body_stream`. Those handlers consume the stream directly
197        // — spooling chunks to disk while computing MD5 + size in
198        // constant memory — so a 1 GiB upload never materializes into
199        // RAM. Every *other* PUT-on-bucket-key the streaming dispatch
200        // flagged (PutObjectTagging, PutObjectAcl, PutObjectRetention,
201        // PutObjectLegalHold, CopyObject, …) reads a small XML/JSON
202        // body from `req.body`, so drain the stream for them.
203        let is_put_to_key = req.method == Method::PUT
204            && req.path_segments.len() >= 2
205            && req
206                .path_segments
207                .first()
208                .map(|s| !s.is_empty())
209                .unwrap_or(false);
210        let q = &req.query_params;
211        let is_put_object = is_put_to_key
212            && !q.contains_key("tagging")
213            && !q.contains_key("acl")
214            && !q.contains_key("retention")
215            && !q.contains_key("legal-hold")
216            && !q.contains_key("renameObject")
217            && !q.contains_key("encryption")
218            && !req.headers.contains_key("x-amz-copy-source");
219        // UploadPart requires both partNumber AND uploadId; checking
220        // only partNumber would skip body draining for stray PUTs that
221        // happened to carry a partNumber query param without being a
222        // real multipart upload.
223        let is_upload_part =
224            is_put_to_key && q.contains_key("partNumber") && q.contains_key("uploadId");
225        if !is_put_object && !is_upload_part {
226            if let Some(stream) = req.take_body_stream() {
227                req.body = fakecloud_core::service::drain_request_stream(stream).await?;
228            }
229        }
230
231        // Access point data plane: resolve alias -> bucket before routing.
232        access_points::resolve_access_point(self, &mut req)?;
233
234        let account_id = req.account_id.as_str();
235
236        // S3 Control endpoint (access point management).
237        let host = req
238            .headers
239            .get("host")
240            .and_then(|v| v.to_str().ok())
241            .unwrap_or("");
242        let is_control = host.to_ascii_lowercase().contains("s3-control");
243        if is_control {
244            let v1 = req.path_segments.first().map(|s| s.as_str());
245            let v2 = req.path_segments.get(1).map(|s| s.as_str());
246            let v3 = req.path_segments.get(2).map(|s| s.as_str());
247            if v1 == Some("v20180820") && v2 == Some("accesspoint") {
248                if let Some(name) = v3 {
249                    match req.method {
250                        Method::PUT => return self.create_access_point(account_id, &req, name),
251                        Method::GET => return self.get_access_point(account_id, &req, name),
252                        Method::DELETE => return self.delete_access_point(account_id, &req, name),
253                        _ => {}
254                    }
255                } else if req.method == Method::GET {
256                    return self.list_access_points(account_id, &req);
257                }
258            }
259        }
260
261        // S3 REST routing: method + path segments + query params
262        //
263        // Reject paths that start with `//` (an empty bucket segment). The
264        // path-segment splitter further down filters empty segments out,
265        // which would otherwise let a malformed URL like `PUT //my-key`
266        // collapse to a valid `PUT /my-key` (CreateBucket) and silently
267        // succeed. Real S3 rejects empty bucket segments with
268        // InvalidBucketName, so mirror that.
269        if req.raw_path.starts_with("//") {
270            return Err(AwsServiceError::aws_error(
271                StatusCode::BAD_REQUEST,
272                "InvalidBucketName",
273                "The specified bucket is not valid: bucket name cannot be empty",
274            ));
275        }
276        let bucket = req.path_segments.first().map(|s| s.as_str());
277        // Extract key from the raw path to preserve leading slashes and empty segments.
278        // The raw path is like "/bucket/key/parts" — we strip the bucket prefix.
279        let key = if let Some(b) = bucket {
280            let prefix = format!("/{b}/");
281            if req.raw_path.starts_with(&prefix) && req.raw_path.len() > prefix.len() {
282                let raw_key = &req.raw_path[prefix.len()..];
283                Some(
284                    percent_encoding::percent_decode_str(raw_key)
285                        .decode_utf8_lossy()
286                        .into_owned(),
287                )
288            } else if req.path_segments.len() > 1 {
289                let raw = req.path_segments[1..].join("/");
290                Some(
291                    percent_encoding::percent_decode_str(&raw)
292                        .decode_utf8_lossy()
293                        .into_owned(),
294                )
295            } else {
296                None
297            }
298        } else {
299            None
300        };
301
302        // Multipart upload operations (checked before main match)
303        if let Some(b) = bucket {
304            // POST /{bucket}/{key}?uploads — CreateMultipartUpload
305            if req.method == Method::POST
306                && key.is_some()
307                && req.query_params.contains_key("uploads")
308            {
309                return self.create_multipart_upload(account_id, &req, b, key.as_deref().unwrap());
310            }
311
312            // POST /{bucket}/{key}?restore
313            if req.method == Method::POST
314                && key.is_some()
315                && req.query_params.contains_key("restore")
316            {
317                return self.restore_object(account_id, &req, b, key.as_deref().unwrap());
318            }
319
320            // POST /{bucket}/{key}?uploadId=X — CompleteMultipartUpload
321            if req.method == Method::POST && key.is_some() {
322                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
323                    return self.complete_multipart_upload(
324                        account_id,
325                        &req,
326                        b,
327                        key.as_deref().unwrap(),
328                        &upload_id,
329                    );
330                }
331            }
332
333            // PUT /{bucket}/{key}?partNumber=N&uploadId=X — UploadPart or UploadPartCopy
334            if req.method == Method::PUT && key.is_some() {
335                if let (Some(part_num_str), Some(upload_id)) = (
336                    req.query_params.get("partNumber").cloned(),
337                    req.query_params.get("uploadId").cloned(),
338                ) {
339                    if let Ok(part_number) = part_num_str.parse::<i64>() {
340                        if req.headers.contains_key("x-amz-copy-source") {
341                            return self.upload_part_copy(
342                                account_id,
343                                &req,
344                                b,
345                                key.as_deref().unwrap(),
346                                &upload_id,
347                                part_number,
348                            );
349                        }
350                        return self
351                            .upload_part(
352                                account_id,
353                                &req,
354                                b,
355                                key.as_deref().unwrap(),
356                                &upload_id,
357                                part_number,
358                            )
359                            .await;
360                    }
361                }
362            }
363
364            // DELETE /{bucket}/{key}?uploadId=X — AbortMultipartUpload
365            if req.method == Method::DELETE && key.is_some() {
366                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
367                    return self.abort_multipart_upload(
368                        account_id,
369                        b,
370                        key.as_deref().unwrap(),
371                        &upload_id,
372                    );
373                }
374            }
375
376            // GET /{bucket}?uploads — ListMultipartUploads
377            if req.method == Method::GET
378                && key.is_none()
379                && req.query_params.contains_key("uploads")
380            {
381                return self.list_multipart_uploads(account_id, b);
382            }
383
384            // GET /{bucket}/{key}?uploadId=X — ListParts
385            if req.method == Method::GET && key.is_some() {
386                if let Some(upload_id) = req.query_params.get("uploadId").cloned() {
387                    return self.list_parts(
388                        account_id,
389                        &req,
390                        b,
391                        key.as_deref().unwrap(),
392                        &upload_id,
393                    );
394                }
395            }
396        }
397
398        // Handle OPTIONS preflight requests (CORS)
399        if req.method == Method::OPTIONS {
400            if let Some(b_name) = bucket {
401                let cors_config = {
402                    let accounts = self.state.read();
403                    let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
404                    let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
405                    state
406                        .buckets
407                        .get(b_name)
408                        .and_then(|b| b.cors_config.clone())
409                };
410                if let Some(ref config) = cors_config {
411                    let origin = req
412                        .headers
413                        .get("origin")
414                        .and_then(|v| v.to_str().ok())
415                        .unwrap_or("");
416                    let request_method = req
417                        .headers
418                        .get("access-control-request-method")
419                        .and_then(|v| v.to_str().ok())
420                        .unwrap_or("");
421                    let rules = parse_cors_config(config);
422                    if let Some(rule) = find_cors_rule(&rules, origin, Some(request_method)) {
423                        let mut headers = HeaderMap::new();
424                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
425                            "*"
426                        } else {
427                            origin
428                        };
429                        headers.insert(
430                            "access-control-allow-origin",
431                            matched_origin
432                                .parse()
433                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
434                        );
435                        headers.insert(
436                            "access-control-allow-methods",
437                            rule.allowed_methods
438                                .join(", ")
439                                .parse()
440                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
441                        );
442                        if !rule.allowed_headers.is_empty() {
443                            let ah = if rule.allowed_headers.contains(&"*".to_string()) {
444                                req.headers
445                                    .get("access-control-request-headers")
446                                    .and_then(|v| v.to_str().ok())
447                                    .unwrap_or("*")
448                                    .to_string()
449                            } else {
450                                rule.allowed_headers.join(", ")
451                            };
452                            headers.insert(
453                                "access-control-allow-headers",
454                                ah.parse()
455                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
456                            );
457                        }
458                        if let Some(max_age) = rule.max_age_seconds {
459                            headers.insert(
460                                "access-control-max-age",
461                                max_age
462                                    .to_string()
463                                    .parse()
464                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
465                            );
466                        }
467                        return Ok(AwsResponse {
468                            status: StatusCode::OK,
469                            content_type: String::new(),
470                            body: Bytes::new().into(),
471                            headers,
472                        });
473                    }
474                }
475                return Err(AwsServiceError::aws_error(
476                    StatusCode::FORBIDDEN,
477                    "CORSResponse",
478                    "CORS is not enabled for this bucket",
479                ));
480            }
481        }
482
483        // Capture origin for CORS response headers
484        let origin_header = req
485            .headers
486            .get("origin")
487            .and_then(|v| v.to_str().ok())
488            .map(|s| s.to_string());
489
490        // Bucket-scoped sub-resource query params. If a request targets one
491        // of these without a bucket in the path, S3 returns an error rather
492        // than silently treating it as a top-level (service) operation.
493        // Real AWS rejects e.g. `GET /?tagging` with a routing/validation
494        // error; mirroring that prevents bucket-required ops from being
495        // accidentally answered by ListBuckets / WriteGetObjectResponse.
496        let bucket_subresources: &[&str] = &[
497            "tagging",
498            "acl",
499            "versioning",
500            "cors",
501            "notification",
502            "website",
503            "accelerate",
504            "publicAccessBlock",
505            "encryption",
506            "lifecycle",
507            "logging",
508            "policy",
509            "policyStatus",
510            "replication",
511            "ownershipControls",
512            "inventory",
513            "analytics",
514            "intelligent-tiering",
515            "metrics",
516            "requestPayment",
517            "abac",
518            "metadataConfiguration",
519            "metadataTable",
520            "metadataInventoryTable",
521            "metadataJournalTable",
522            "object-lock",
523            "versions",
524            "session",
525            "retention",
526            "legal-hold",
527            "attributes",
528            "torrent",
529            "renameObject",
530            "uploads",
531            "restore",
532            "select",
533            // Bucket-required ops the earlier list missed: their query
534            // markers (e.g. `?location` for GetBucketLocation,
535            // `?delete` for DeleteObjects, `?list-type=2` for
536            // ListObjectsV2) signal a bucket op so a request with the
537            // marker but no bucket segment is malformed, not a service-
538            // level call.
539            "location",
540            "delete",
541            "list-type",
542        ];
543        if bucket.is_none()
544            && bucket_subresources
545                .iter()
546                .any(|q| req.query_params.contains_key(*q))
547        {
548            return Err(AwsServiceError::aws_error(
549                StatusCode::BAD_REQUEST,
550                "InvalidBucketName",
551                "The specified bucket is not valid: bucket name is required for this operation",
552            ));
553        }
554
555        let mut result = match (&req.method, bucket, key.as_deref()) {
556            // ListBuckets: GET /
557            (&Method::GET, None, None) => {
558                if req.query_params.get("x-id").map(|s| s.as_str()) == Some("ListDirectoryBuckets")
559                {
560                    self.list_directory_buckets(account_id, &req)
561                } else {
562                    self.list_buckets(account_id, &req)
563                }
564            }
565
566            // Bucket-level operations (no key)
567            (&Method::PUT, Some(b), None) => {
568                if req.query_params.contains_key("tagging") {
569                    self.put_bucket_tagging(account_id, &req, b)
570                } else if req.query_params.contains_key("acl") {
571                    self.put_bucket_acl(account_id, &req, b)
572                } else if req.query_params.contains_key("versioning") {
573                    self.put_bucket_versioning(account_id, &req, b)
574                } else if req.query_params.contains_key("cors") {
575                    self.put_bucket_cors(account_id, &req, b)
576                } else if req.query_params.contains_key("notification") {
577                    self.put_bucket_notification(account_id, &req, b)
578                } else if req.query_params.contains_key("website") {
579                    self.put_bucket_website(account_id, &req, b)
580                } else if req.query_params.contains_key("accelerate") {
581                    self.put_bucket_accelerate(account_id, &req, b)
582                } else if req.query_params.contains_key("publicAccessBlock") {
583                    self.put_public_access_block(account_id, &req, b)
584                } else if req.query_params.contains_key("encryption") {
585                    self.put_bucket_encryption(account_id, &req, b)
586                } else if req.query_params.contains_key("lifecycle") {
587                    self.put_bucket_lifecycle(account_id, &req, b)
588                } else if req.query_params.contains_key("logging") {
589                    self.put_bucket_logging(account_id, &req, b)
590                } else if req.query_params.contains_key("policy") {
591                    self.put_bucket_policy(account_id, &req, b)
592                } else if req.query_params.contains_key("object-lock") {
593                    self.put_object_lock_config(account_id, &req, b)
594                } else if req.query_params.contains_key("replication") {
595                    self.put_bucket_replication(account_id, &req, b)
596                } else if req.query_params.contains_key("ownershipControls") {
597                    self.put_bucket_ownership_controls(account_id, &req, b)
598                } else if req.query_params.contains_key("inventory") {
599                    self.put_bucket_inventory(account_id, &req, b)
600                } else if req.query_params.contains_key("analytics") {
601                    self.put_bucket_analytics_config(account_id, &req, b)
602                } else if req.query_params.contains_key("intelligent-tiering") {
603                    self.put_bucket_intelligent_tiering_config(account_id, &req, b)
604                } else if req.query_params.contains_key("metrics") {
605                    self.put_bucket_metrics_config(account_id, &req, b)
606                } else if req.query_params.contains_key("requestPayment") {
607                    self.put_bucket_request_payment(account_id, &req, b)
608                } else if req.query_params.contains_key("abac") {
609                    self.put_bucket_abac(account_id, &req, b)
610                } else if req.query_params.contains_key("metadataInventoryTable") {
611                    self.update_bucket_metadata_inventory_table(account_id, &req, b)
612                } else if req.query_params.contains_key("metadataJournalTable") {
613                    self.update_bucket_metadata_journal_table(account_id, &req, b)
614                } else {
615                    self.create_bucket(account_id, &req, b)
616                }
617            }
618            (&Method::DELETE, Some(b), None) => {
619                if req.query_params.contains_key("tagging") {
620                    self.delete_bucket_tagging(account_id, &req, b)
621                } else if req.query_params.contains_key("cors") {
622                    self.delete_bucket_cors(account_id, b)
623                } else if req.query_params.contains_key("website") {
624                    self.delete_bucket_website(account_id, b)
625                } else if req.query_params.contains_key("publicAccessBlock") {
626                    self.delete_public_access_block(account_id, b)
627                } else if req.query_params.contains_key("encryption") {
628                    self.delete_bucket_encryption(account_id, b)
629                } else if req.query_params.contains_key("lifecycle") {
630                    self.delete_bucket_lifecycle(account_id, b)
631                } else if req.query_params.contains_key("policy") {
632                    self.delete_bucket_policy(account_id, b)
633                } else if req.query_params.contains_key("replication") {
634                    self.delete_bucket_replication(account_id, b)
635                } else if req.query_params.contains_key("ownershipControls") {
636                    self.delete_bucket_ownership_controls(account_id, b)
637                } else if req.query_params.contains_key("inventory") {
638                    self.delete_bucket_inventory(account_id, &req, b)
639                } else if req.query_params.contains_key("analytics") {
640                    self.delete_bucket_analytics_config(account_id, &req, b)
641                } else if req.query_params.contains_key("intelligent-tiering") {
642                    self.delete_bucket_intelligent_tiering_config(account_id, &req, b)
643                } else if req.query_params.contains_key("metrics") {
644                    self.delete_bucket_metrics_config(account_id, &req, b)
645                } else if req.query_params.contains_key("metadataConfiguration") {
646                    self.delete_bucket_metadata_config(account_id, b)
647                } else if req.query_params.contains_key("metadataTable") {
648                    self.delete_bucket_metadata_table_config(account_id, b)
649                } else {
650                    self.delete_bucket(account_id, &req, b)
651                }
652            }
653            (&Method::HEAD, Some(b), None) => self.head_bucket(account_id, b),
654            (&Method::GET, Some(b), None) => {
655                if req.query_params.contains_key("tagging") {
656                    self.get_bucket_tagging(account_id, &req, b)
657                } else if req.query_params.contains_key("location") {
658                    self.get_bucket_location(account_id, b)
659                } else if req.query_params.contains_key("acl") {
660                    self.get_bucket_acl(account_id, &req, b)
661                } else if req.query_params.contains_key("versioning") {
662                    self.get_bucket_versioning(account_id, b)
663                } else if req.query_params.contains_key("versions") {
664                    self.list_object_versions(account_id, &req, b)
665                } else if req.query_params.contains_key("object-lock") {
666                    self.get_object_lock_configuration(account_id, b)
667                } else if req.query_params.contains_key("cors") {
668                    self.get_bucket_cors(account_id, b)
669                } else if req.query_params.contains_key("notification") {
670                    self.get_bucket_notification(account_id, b)
671                } else if req.query_params.contains_key("website") {
672                    self.get_bucket_website(account_id, b)
673                } else if req.query_params.contains_key("accelerate") {
674                    self.get_bucket_accelerate(account_id, b)
675                } else if req.query_params.contains_key("publicAccessBlock") {
676                    self.get_public_access_block(account_id, b)
677                } else if req.query_params.contains_key("encryption") {
678                    self.get_bucket_encryption(account_id, b)
679                } else if req.query_params.contains_key("lifecycle") {
680                    self.get_bucket_lifecycle(account_id, b)
681                } else if req.query_params.contains_key("logging") {
682                    self.get_bucket_logging(account_id, b)
683                } else if req.query_params.contains_key("policy") {
684                    self.get_bucket_policy(account_id, b)
685                } else if req.query_params.contains_key("replication") {
686                    self.get_bucket_replication(account_id, b)
687                } else if req.query_params.contains_key("ownershipControls") {
688                    self.get_bucket_ownership_controls(account_id, b)
689                } else if req.query_params.contains_key("inventory") {
690                    if req.query_params.contains_key("id") {
691                        self.get_bucket_inventory(account_id, &req, b)
692                    } else {
693                        self.list_bucket_inventory_configurations(account_id, b)
694                    }
695                } else if req.query_params.contains_key("analytics") {
696                    if req.query_params.contains_key("id") {
697                        self.get_bucket_analytics_config(account_id, &req, b)
698                    } else {
699                        self.list_bucket_analytics_configurations(account_id, b)
700                    }
701                } else if req.query_params.contains_key("intelligent-tiering") {
702                    if req.query_params.contains_key("id") {
703                        self.get_bucket_intelligent_tiering_config(account_id, &req, b)
704                    } else {
705                        self.list_bucket_intelligent_tiering_configurations(account_id, b)
706                    }
707                } else if req.query_params.contains_key("metrics") {
708                    if req.query_params.contains_key("id") {
709                        self.get_bucket_metrics_config(account_id, &req, b)
710                    } else {
711                        self.list_bucket_metrics_configurations(account_id, b)
712                    }
713                } else if req.query_params.contains_key("requestPayment") {
714                    self.get_bucket_request_payment(account_id, b)
715                } else if req.query_params.contains_key("abac") {
716                    self.get_bucket_abac(account_id, b)
717                } else if req.query_params.contains_key("policyStatus") {
718                    self.get_bucket_policy_status(account_id, b)
719                } else if req.query_params.contains_key("metadataConfiguration") {
720                    self.get_bucket_metadata_config(account_id, b)
721                } else if req.query_params.contains_key("metadataTable") {
722                    self.get_bucket_metadata_table_config(account_id, b)
723                } else if req.query_params.contains_key("session") {
724                    self.create_session(account_id, &req, b)
725                } else if req.query_params.get("list-type").map(|s| s.as_str()) == Some("2") {
726                    self.list_objects_v2(account_id, &req, b)
727                } else if req.query_params.is_empty() {
728                    // If bucket has website config and no query params, serve index document
729                    let website_config = {
730                        let accounts = self.state.read();
731                        let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
732                        let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
733                        state
734                            .buckets
735                            .get(b)
736                            .and_then(|bkt| bkt.website_config.clone())
737                    };
738                    if let Some(ref config) = website_config {
739                        if let Some(index_doc) = extract_xml_value(config, "Suffix").or_else(|| {
740                            extract_xml_value(config, "IndexDocument").and_then(|inner| {
741                                let open = "<Suffix>";
742                                let close = "</Suffix>";
743                                let s = inner.find(open)? + open.len();
744                                let e = inner.find(close)?;
745                                Some(inner[s..e].trim().to_string())
746                            })
747                        }) {
748                            self.serve_website_object(account_id, &req, b, &index_doc, config)
749                        } else {
750                            self.list_objects_v1(account_id, &req, b)
751                        }
752                    } else {
753                        self.list_objects_v1(account_id, &req, b)
754                    }
755                } else {
756                    self.list_objects_v1(account_id, &req, b)
757                }
758            }
759
760            // Object-level operations
761            (&Method::PUT, Some(b), Some(k)) => {
762                if req.query_params.contains_key("tagging") {
763                    self.put_object_tagging(account_id, &req, b, k)
764                } else if req.query_params.contains_key("acl") {
765                    self.put_object_acl(account_id, &req, b, k)
766                } else if req.query_params.contains_key("retention") {
767                    self.put_object_retention(account_id, &req, b, k)
768                } else if req.query_params.contains_key("legal-hold") {
769                    self.put_object_legal_hold(account_id, &req, b, k)
770                } else if req.query_params.contains_key("renameObject") {
771                    self.rename_object(account_id, &req, b, k)
772                } else if req.query_params.contains_key("encryption") {
773                    self.update_object_encryption(account_id, &req, b, k)
774                } else if req.headers.contains_key("x-amz-copy-source") {
775                    self.copy_object(account_id, &req, b, k)
776                } else {
777                    self.put_object(account_id, &req, b, k).await
778                }
779            }
780            (&Method::GET, Some(b), Some(k)) => {
781                if req.query_params.contains_key("tagging") {
782                    self.get_object_tagging(account_id, &req, b, k)
783                } else if req.query_params.contains_key("acl") {
784                    self.get_object_acl(account_id, &req, b, k)
785                } else if req.query_params.contains_key("retention") {
786                    self.get_object_retention(account_id, &req, b, k)
787                } else if req.query_params.contains_key("legal-hold") {
788                    self.get_object_legal_hold(account_id, &req, b, k)
789                } else if req.query_params.contains_key("attributes") {
790                    self.get_object_attributes(account_id, &req, b, k)
791                } else if req.query_params.contains_key("torrent") {
792                    self.get_object_torrent(account_id, &req, b, k)
793                } else {
794                    let result = self.get_object(account_id, &req, b, k);
795                    // If object not found and bucket has website config, serve error document
796                    let is_not_found = matches!(
797                        &result,
798                        Err(e) if e.code() == "NoSuchKey"
799                    );
800                    if is_not_found {
801                        let website_config = {
802                            let accounts = self.state.read();
803                            let _empty_s3 =
804                                crate::state::S3State::new(&req.account_id, &req.region);
805                            let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
806                            state
807                                .buckets
808                                .get(b)
809                                .and_then(|bkt| bkt.website_config.clone())
810                        };
811                        if let Some(ref config) = website_config {
812                            if let Some(error_key) = extract_xml_value(config, "ErrorDocument")
813                                .and_then(|inner| {
814                                    let open = "<Key>";
815                                    let close = "</Key>";
816                                    let s = inner.find(open)? + open.len();
817                                    let e = inner.find(close)?;
818                                    Some(inner[s..e].trim().to_string())
819                                })
820                                .or_else(|| extract_xml_value(config, "Key"))
821                            {
822                                return self.serve_website_error(account_id, &req, b, &error_key);
823                            }
824                        }
825                    }
826                    result
827                }
828            }
829            (&Method::DELETE, Some(b), Some(k)) => {
830                if req.query_params.contains_key("tagging") {
831                    self.delete_object_tagging(account_id, b, k)
832                } else {
833                    self.delete_object(account_id, &req, b, k)
834                }
835            }
836            (&Method::HEAD, Some(b), Some(k)) => self.head_object(account_id, &req, b, k),
837
838            // POST /{bucket}?delete — batch delete
839            (&Method::POST, Some(b), None) if req.query_params.contains_key("delete") => {
840                self.delete_objects(account_id, &req, b)
841            }
842            (&Method::POST, Some(b), None)
843                if req.query_params.contains_key("metadataConfiguration") =>
844            {
845                self.create_bucket_metadata_config(account_id, &req, b)
846            }
847            (&Method::POST, Some(b), None) if req.query_params.contains_key("metadataTable") => {
848                self.create_bucket_metadata_table_config(account_id, &req, b)
849            }
850            (&Method::POST, Some(b), Some(k))
851                if req.query_params.get("select-type").map(|s| s.as_str()) == Some("2") =>
852            {
853                self.select_object_content(account_id, &req, b, k)
854            }
855            (&Method::POST, Some("WriteGetObjectResponse"), None) => {
856                self.write_get_object_response(account_id, &req)
857            }
858
859            _ => Err(AwsServiceError::aws_error(
860                StatusCode::METHOD_NOT_ALLOWED,
861                "MethodNotAllowed",
862                "The specified method is not allowed against this resource",
863            )),
864        };
865
866        // Apply CORS headers to the response if Origin was present
867        if let (Some(ref origin), Some(b_name)) = (&origin_header, bucket) {
868            let cors_config = {
869                let accounts = self.state.read();
870                let _empty_s3 = crate::state::S3State::new(&req.account_id, &req.region);
871                let state = accounts.get(&req.account_id).unwrap_or(&_empty_s3);
872                state
873                    .buckets
874                    .get(b_name)
875                    .and_then(|b| b.cors_config.clone())
876            };
877            if let Some(ref config) = cors_config {
878                let rules = parse_cors_config(config);
879                if let Some(rule) = find_cors_rule(&rules, origin, None) {
880                    if let Ok(ref mut resp) = result {
881                        let matched_origin = if rule.allowed_origins.contains(&"*".to_string()) {
882                            "*"
883                        } else {
884                            origin
885                        };
886                        resp.headers.insert(
887                            "access-control-allow-origin",
888                            matched_origin
889                                .parse()
890                                .unwrap_or_else(|_| http::HeaderValue::from_static("")),
891                        );
892                        if !rule.expose_headers.is_empty() {
893                            resp.headers.insert(
894                                "access-control-expose-headers",
895                                rule.expose_headers
896                                    .join(", ")
897                                    .parse()
898                                    .unwrap_or_else(|_| http::HeaderValue::from_static("")),
899                            );
900                        }
901                    }
902                }
903            }
904        }
905
906        // Write S3 access log entry if the source bucket has logging enabled
907        if let Some(b_name) = bucket {
908            let status_code = match &result {
909                Ok(resp) => resp.status.as_u16(),
910                Err(e) => e.status().as_u16(),
911            };
912            let op = logging::operation_name(&req.method, key.as_deref());
913            logging::maybe_write_access_log(
914                &self.state,
915                &self.store,
916                b_name,
917                &logging::AccessLogRequest {
918                    operation: op,
919                    key: key.as_deref(),
920                    status: status_code,
921                    request_id: &req.request_id,
922                    method: req.method.as_str(),
923                    path: &req.raw_path,
924                },
925            );
926        }
927
928        result
929    }
930
931    fn supported_actions(&self) -> &[&str] {
932        &[
933            // Buckets
934            "ListBuckets",
935            "CreateBucket",
936            "DeleteBucket",
937            "HeadBucket",
938            "GetBucketLocation",
939            // Objects
940            "PutObject",
941            "GetObject",
942            "DeleteObject",
943            "HeadObject",
944            "CopyObject",
945            "DeleteObjects",
946            "ListObjectsV2",
947            "ListObjects",
948            "ListObjectVersions",
949            "GetObjectAttributes",
950            "RestoreObject",
951            // Object properties
952            "PutObjectTagging",
953            "GetObjectTagging",
954            "DeleteObjectTagging",
955            "PutObjectAcl",
956            "GetObjectAcl",
957            "PutObjectRetention",
958            "GetObjectRetention",
959            "PutObjectLegalHold",
960            "GetObjectLegalHold",
961            // Bucket configuration
962            "PutBucketTagging",
963            "GetBucketTagging",
964            "DeleteBucketTagging",
965            "PutBucketAcl",
966            "GetBucketAcl",
967            "PutBucketVersioning",
968            "GetBucketVersioning",
969            "PutBucketCors",
970            "GetBucketCors",
971            "DeleteBucketCors",
972            "PutBucketNotificationConfiguration",
973            "GetBucketNotificationConfiguration",
974            "PutBucketWebsite",
975            "GetBucketWebsite",
976            "DeleteBucketWebsite",
977            "PutBucketAccelerateConfiguration",
978            "GetBucketAccelerateConfiguration",
979            "PutPublicAccessBlock",
980            "GetPublicAccessBlock",
981            "DeletePublicAccessBlock",
982            "PutBucketEncryption",
983            "GetBucketEncryption",
984            "DeleteBucketEncryption",
985            "PutBucketLifecycleConfiguration",
986            "GetBucketLifecycleConfiguration",
987            "DeleteBucketLifecycle",
988            "PutBucketLogging",
989            "GetBucketLogging",
990            "PutBucketPolicy",
991            "GetBucketPolicy",
992            "DeleteBucketPolicy",
993            "PutObjectLockConfiguration",
994            "GetObjectLockConfiguration",
995            "PutBucketReplication",
996            "GetBucketReplication",
997            "DeleteBucketReplication",
998            "PutBucketOwnershipControls",
999            "GetBucketOwnershipControls",
1000            "DeleteBucketOwnershipControls",
1001            "PutBucketInventoryConfiguration",
1002            "GetBucketInventoryConfiguration",
1003            "DeleteBucketInventoryConfiguration",
1004            "ListBucketInventoryConfigurations",
1005            "PutBucketAnalyticsConfiguration",
1006            "GetBucketAnalyticsConfiguration",
1007            "DeleteBucketAnalyticsConfiguration",
1008            "ListBucketAnalyticsConfigurations",
1009            "PutBucketIntelligentTieringConfiguration",
1010            "GetBucketIntelligentTieringConfiguration",
1011            "DeleteBucketIntelligentTieringConfiguration",
1012            "ListBucketIntelligentTieringConfigurations",
1013            "PutBucketMetricsConfiguration",
1014            "GetBucketMetricsConfiguration",
1015            "DeleteBucketMetricsConfiguration",
1016            "ListBucketMetricsConfigurations",
1017            "PutBucketRequestPayment",
1018            "GetBucketRequestPayment",
1019            "PutBucketAbac",
1020            "GetBucketAbac",
1021            "GetBucketPolicyStatus",
1022            "CreateBucketMetadataConfiguration",
1023            "GetBucketMetadataConfiguration",
1024            "DeleteBucketMetadataConfiguration",
1025            "CreateBucketMetadataTableConfiguration",
1026            "GetBucketMetadataTableConfiguration",
1027            "DeleteBucketMetadataTableConfiguration",
1028            "UpdateBucketMetadataInventoryTableConfiguration",
1029            "UpdateBucketMetadataJournalTableConfiguration",
1030            "GetObjectTorrent",
1031            "RenameObject",
1032            "SelectObjectContent",
1033            "UpdateObjectEncryption",
1034            "WriteGetObjectResponse",
1035            "ListDirectoryBuckets",
1036            "CreateSession",
1037            // Multipart uploads
1038            "CreateMultipartUpload",
1039            "UploadPart",
1040            "UploadPartCopy",
1041            "CompleteMultipartUpload",
1042            "AbortMultipartUpload",
1043            "ListParts",
1044            "ListMultipartUploads",
1045        ]
1046    }
1047
1048    fn iam_enforceable(&self) -> bool {
1049        true
1050    }
1051
1052    /// S3 resources are either:
1053    /// - Bucket ARN (`arn:aws:s3:::bucket`) for bucket-level actions
1054    /// - Object ARN (`arn:aws:s3:::bucket/key`) for object-level actions
1055    /// - Wildcard (`*`) for `ListBuckets` which doesn't target a specific
1056    ///   resource
1057    ///
1058    /// S3 ARNs notably omit the account id and region — this is the one
1059    /// AWS service that carries neither in its ARN, because bucket names
1060    /// are globally unique.
1061    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
1062        // S3 doesn't set `request.action` — the handler dispatches on
1063        // method + path + query params directly. Re-derive the action
1064        // name here so enforcement can match against IAM policies the
1065        // same way the real service would.
1066        let bucket = request.path_segments.first().map(|s| s.as_str());
1067        let key = if request.path_segments.len() > 1 {
1068            Some(request.path_segments[1..].join("/"))
1069        } else {
1070            None
1071        };
1072        let action = s3_detect_action(
1073            request.method.as_str(),
1074            bucket,
1075            key.as_deref(),
1076            &request.query_params,
1077        )?;
1078        let resource = s3_resource_for(action, bucket, key.as_deref());
1079        Some(fakecloud_core::auth::IamAction {
1080            service: "s3",
1081            action,
1082            resource,
1083        })
1084    }
1085
1086    fn iam_condition_keys_for(
1087        &self,
1088        request: &AwsRequest,
1089        action: &fakecloud_core::auth::IamAction,
1090    ) -> std::collections::BTreeMap<String, Vec<String>> {
1091        s3_condition_keys(action.action, &request.query_params)
1092    }
1093
1094    fn resource_tags_for(
1095        &self,
1096        resource_arn: &str,
1097    ) -> Option<std::collections::HashMap<String, String>> {
1098        s3_resource_tags(&self.state, resource_arn)
1099            .map(|m| m.into_iter().collect::<std::collections::HashMap<_, _>>())
1100    }
1101
1102    fn request_tags_from(
1103        &self,
1104        request: &AwsRequest,
1105        action: &str,
1106    ) -> Option<std::collections::HashMap<String, String>> {
1107        s3_request_tags(request, action)
1108            .map(|m| m.into_iter().collect::<std::collections::HashMap<_, _>>())
1109    }
1110}
1111
1112/// Extract service-specific IAM condition keys from an S3 request.
1113///
1114/// Today only `ListObjects` / `ListObjectsV2` expose keys (`s3:prefix`,
1115/// `s3:delimiter`, `s3:max-keys`) via their query params. Other actions
1116/// return an empty map so the evaluator's safe-fail semantics treat any
1117/// policy condition referencing an unknown key as "doesn't apply".
1118fn s3_condition_keys(
1119    action: &str,
1120    query: &std::collections::HashMap<String, String>,
1121) -> std::collections::BTreeMap<String, Vec<String>> {
1122    let mut out = std::collections::BTreeMap::new();
1123    if matches!(action, "ListObjects" | "ListObjectsV2") {
1124        // Both list variants share the same query param shape.
1125        if let Some(prefix) = query.get("prefix") {
1126            out.insert("s3:prefix".to_string(), vec![prefix.clone()]);
1127        }
1128        if let Some(delimiter) = query.get("delimiter") {
1129            out.insert("s3:delimiter".to_string(), vec![delimiter.clone()]);
1130        }
1131        if let Some(max_keys) = query.get("max-keys") {
1132            out.insert("s3:max-keys".to_string(), vec![max_keys.clone()]);
1133        }
1134    }
1135    out
1136}
1137
1138/// Look up resource tags for an S3 ARN.
1139///
1140/// Bucket-level ARN (`arn:aws:s3:::bucket`) -> bucket tags.
1141/// Object-level ARN (`arn:aws:s3:::bucket/key`) -> object tags.
1142/// `*` (ListBuckets) -> `Some(empty)` (no resource to tag).
1143fn s3_resource_tags(
1144    state: &SharedS3State,
1145    resource_arn: &str,
1146) -> Option<std::collections::BTreeMap<String, String>> {
1147    if resource_arn == "*" {
1148        return Some(std::collections::BTreeMap::new());
1149    }
1150    // S3 ARNs: arn:aws:s3:::bucket or arn:aws:s3:::bucket/key
1151    let after_prefix = resource_arn.strip_prefix("arn:aws:s3:::")?;
1152    let mas = state.read();
1153    // S3 bucket names are globally unique; scan all accounts to find the bucket
1154    let bucket_name = after_prefix.split('/').next().unwrap_or(after_prefix);
1155    let state = mas
1156        .find_account(|s| s.buckets.contains_key(bucket_name))
1157        .and_then(|id| mas.get(id))
1158        .or_else(|| Some(mas.default_ref()))?;
1159    if let Some(slash_pos) = after_prefix.find('/') {
1160        // Object-level: bucket/key
1161        let bucket_name = &after_prefix[..slash_pos];
1162        let key = &after_prefix[slash_pos + 1..];
1163        let bucket = state.buckets.get(bucket_name)?;
1164        // Try current objects first, then versioned objects (latest version)
1165        if let Some(obj) = bucket.objects.get(key) {
1166            Some(obj.tags.clone())
1167        } else if let Some(versions) = bucket.object_versions.get(key) {
1168            versions.last().map(|v| v.tags.clone())
1169        } else {
1170            // Object doesn't exist yet (e.g. PutObject creating it)
1171            Some(std::collections::BTreeMap::new())
1172        }
1173    } else {
1174        // Bucket-level
1175        let bucket = state.buckets.get(after_prefix)?;
1176        Some(bucket.tags.clone())
1177    }
1178}
1179
1180/// Extract tags from an S3 request body/headers.
1181///
1182/// S3 sends tags via:
1183/// - `x-amz-tagging` header (URL-encoded `key=value&...`) on PutObject
1184/// - XML body on PutBucketTagging / PutObjectTagging
1185fn s3_request_tags(
1186    request: &AwsRequest,
1187    action: &str,
1188) -> Option<std::collections::BTreeMap<String, String>> {
1189    match action {
1190        "PutObject" | "CopyObject" | "CreateMultipartUpload" => {
1191            // Tags come via x-amz-tagging header
1192            if let Some(tagging) = request.headers.get("x-amz-tagging") {
1193                let tags = parse_url_encoded_tags(tagging.to_str().unwrap_or(""));
1194                Some(tags.into_iter().collect())
1195            } else {
1196                Some(std::collections::BTreeMap::new())
1197            }
1198        }
1199        "PutBucketTagging" | "PutObjectTagging" => {
1200            // Tags come in XML body
1201            let body = std::str::from_utf8(&request.body).unwrap_or("");
1202            let tags = parse_tagging_xml(body);
1203            Some(tags.into_iter().collect())
1204        }
1205        _ => Some(std::collections::BTreeMap::new()),
1206    }
1207}
1208
1209/// Derive the IAM action name from an S3 REST request. Handles the
1210/// common cases (GetObject, PutObject, DeleteObject, ListObjectsV2,
1211/// CreateBucket, ...) plus a subset of sub-resource operations
1212/// (`?acl`, `?tagging`, `?versioning`, `?policy`, `?cors`, `?website`,
1213/// `?lifecycle`, `?encryption`, `?logging`, `?notification`, `?replication`,
1214/// `?ownershipControls`, `?publicAccessBlock`, `?accelerate`, `?inventory`,
1215/// `?object-lock`, `?uploads`, `?uploadId`).
1216///
1217/// Returns `None` for requests that don't map to a known action — the
1218/// dispatch layer then skips enforcement for that request rather than
1219/// guessing (and a warn log fires via the "service is iam_enforceable
1220/// but has no mapping" branch in dispatch.rs).
1221fn s3_detect_action(
1222    method: &str,
1223    bucket: Option<&str>,
1224    key: Option<&str>,
1225    query: &std::collections::HashMap<String, String>,
1226) -> Option<&'static str> {
1227    let has = |q: &str| query.contains_key(q);
1228    let is_get = method == "GET";
1229    let is_put = method == "PUT";
1230    let is_post = method == "POST";
1231    let is_delete = method == "DELETE";
1232
1233    // Service root
1234    if bucket.is_none() {
1235        return match method {
1236            "GET" => Some("ListBuckets"),
1237            _ => None,
1238        };
1239    }
1240    let has_key = key.is_some();
1241
1242    // Multipart sub-resource forms
1243    if has_key && is_post && has("uploads") {
1244        return Some("CreateMultipartUpload");
1245    }
1246    if has_key && is_post && has("uploadId") {
1247        return Some("CompleteMultipartUpload");
1248    }
1249    if has_key && is_put && has("partNumber") && has("uploadId") {
1250        return Some("UploadPart");
1251    }
1252    if has_key && is_delete && has("uploadId") {
1253        return Some("AbortMultipartUpload");
1254    }
1255    if has_key && is_get && has("uploadId") {
1256        return Some("ListParts");
1257    }
1258    if !has_key && is_get && has("uploads") {
1259        return Some("ListMultipartUploads");
1260    }
1261
1262    // Sub-resource-keyed actions (?acl, ?tagging, ...). Order matters
1263    // since a request can carry multiple; we pick the most specific.
1264    // Object-level sub-resources come first (key present).
1265    if has_key {
1266        if has("tagging") {
1267            return Some(match method {
1268                "GET" => "GetObjectTagging",
1269                "PUT" => "PutObjectTagging",
1270                "DELETE" => "DeleteObjectTagging",
1271                _ => return None,
1272            });
1273        }
1274        if has("acl") {
1275            return Some(match method {
1276                "GET" => "GetObjectAcl",
1277                "PUT" => "PutObjectAcl",
1278                _ => return None,
1279            });
1280        }
1281        if has("retention") {
1282            return Some(match method {
1283                "GET" => "GetObjectRetention",
1284                "PUT" => "PutObjectRetention",
1285                _ => return None,
1286            });
1287        }
1288        if has("legal-hold") {
1289            return Some(match method {
1290                "GET" => "GetObjectLegalHold",
1291                "PUT" => "PutObjectLegalHold",
1292                _ => return None,
1293            });
1294        }
1295        // Identified by cubic on PR #399: both ?attributes and ?restore
1296        // need method guards — otherwise e.g. GET /bucket/key?restore
1297        // would be classified as RestoreObject (POST-only in AWS) and
1298        // IAM-evaluated against s3:RestoreObject instead of s3:GetObject.
1299        if has("attributes") && is_get {
1300            return Some("GetObjectAttributes");
1301        }
1302        if has("restore") && is_post {
1303            return Some("RestoreObject");
1304        }
1305    }
1306
1307    // Bucket-level sub-resources (key absent).
1308    if !has_key {
1309        if has("tagging") {
1310            return Some(match method {
1311                "GET" => "GetBucketTagging",
1312                "PUT" => "PutBucketTagging",
1313                "DELETE" => "DeleteBucketTagging",
1314                _ => return None,
1315            });
1316        }
1317        if has("acl") {
1318            return Some(match method {
1319                "GET" => "GetBucketAcl",
1320                "PUT" => "PutBucketAcl",
1321                _ => return None,
1322            });
1323        }
1324        if has("versioning") {
1325            return Some(match method {
1326                "GET" => "GetBucketVersioning",
1327                "PUT" => "PutBucketVersioning",
1328                _ => return None,
1329            });
1330        }
1331        if has("cors") {
1332            return Some(match method {
1333                "GET" => "GetBucketCors",
1334                "PUT" => "PutBucketCors",
1335                "DELETE" => "DeleteBucketCors",
1336                _ => return None,
1337            });
1338        }
1339        if has("policy") {
1340            return Some(match method {
1341                "GET" => "GetBucketPolicy",
1342                "PUT" => "PutBucketPolicy",
1343                "DELETE" => "DeleteBucketPolicy",
1344                _ => return None,
1345            });
1346        }
1347        if has("website") {
1348            return Some(match method {
1349                "GET" => "GetBucketWebsite",
1350                "PUT" => "PutBucketWebsite",
1351                "DELETE" => "DeleteBucketWebsite",
1352                _ => return None,
1353            });
1354        }
1355        if has("lifecycle") {
1356            return Some(match method {
1357                "GET" => "GetBucketLifecycleConfiguration",
1358                "PUT" => "PutBucketLifecycleConfiguration",
1359                "DELETE" => "DeleteBucketLifecycle",
1360                _ => return None,
1361            });
1362        }
1363        if has("encryption") {
1364            return Some(match method {
1365                "GET" => "GetBucketEncryption",
1366                "PUT" => "PutBucketEncryption",
1367                "DELETE" => "DeleteBucketEncryption",
1368                _ => return None,
1369            });
1370        }
1371        if has("logging") {
1372            return Some(match method {
1373                "GET" => "GetBucketLogging",
1374                "PUT" => "PutBucketLogging",
1375                _ => return None,
1376            });
1377        }
1378        if has("notification") {
1379            return Some(match method {
1380                "GET" => "GetBucketNotificationConfiguration",
1381                "PUT" => "PutBucketNotificationConfiguration",
1382                _ => return None,
1383            });
1384        }
1385        if has("replication") {
1386            return Some(match method {
1387                "GET" => "GetBucketReplication",
1388                "PUT" => "PutBucketReplication",
1389                "DELETE" => "DeleteBucketReplication",
1390                _ => return None,
1391            });
1392        }
1393        if has("ownershipControls") {
1394            return Some(match method {
1395                "GET" => "GetBucketOwnershipControls",
1396                "PUT" => "PutBucketOwnershipControls",
1397                "DELETE" => "DeleteBucketOwnershipControls",
1398                _ => return None,
1399            });
1400        }
1401        if has("publicAccessBlock") {
1402            return Some(match method {
1403                "GET" => "GetPublicAccessBlock",
1404                "PUT" => "PutPublicAccessBlock",
1405                "DELETE" => "DeletePublicAccessBlock",
1406                _ => return None,
1407            });
1408        }
1409        if has("accelerate") {
1410            return Some(match method {
1411                "GET" => "GetBucketAccelerateConfiguration",
1412                "PUT" => "PutBucketAccelerateConfiguration",
1413                _ => return None,
1414            });
1415        }
1416        if has("inventory") {
1417            return Some(match method {
1418                "GET" => "GetBucketInventoryConfiguration",
1419                "PUT" => "PutBucketInventoryConfiguration",
1420                "DELETE" => "DeleteBucketInventoryConfiguration",
1421                _ => return None,
1422            });
1423        }
1424        if has("analytics") {
1425            return Some(match method {
1426                "GET" if has("id") => "GetBucketAnalyticsConfiguration",
1427                "GET" => "ListBucketAnalyticsConfigurations",
1428                "PUT" => "PutBucketAnalyticsConfiguration",
1429                "DELETE" => "DeleteBucketAnalyticsConfiguration",
1430                _ => return None,
1431            });
1432        }
1433        if has("intelligent-tiering") {
1434            return Some(match method {
1435                "GET" if has("id") => "GetBucketIntelligentTieringConfiguration",
1436                "GET" => "ListBucketIntelligentTieringConfigurations",
1437                "PUT" => "PutBucketIntelligentTieringConfiguration",
1438                "DELETE" => "DeleteBucketIntelligentTieringConfiguration",
1439                _ => return None,
1440            });
1441        }
1442        if has("metrics") {
1443            return Some(match method {
1444                "GET" if has("id") => "GetBucketMetricsConfiguration",
1445                "GET" => "ListBucketMetricsConfigurations",
1446                "PUT" => "PutBucketMetricsConfiguration",
1447                "DELETE" => "DeleteBucketMetricsConfiguration",
1448                _ => return None,
1449            });
1450        }
1451        if has("requestPayment") {
1452            return Some(match method {
1453                "GET" => "GetBucketRequestPayment",
1454                "PUT" => "PutBucketRequestPayment",
1455                _ => return None,
1456            });
1457        }
1458        if has("policyStatus") && is_get {
1459            return Some("GetBucketPolicyStatus");
1460        }
1461        if has("metadataConfiguration") {
1462            return Some(match method {
1463                "GET" => "GetBucketMetadataConfiguration",
1464                "POST" => "CreateBucketMetadataConfiguration",
1465                "DELETE" => "DeleteBucketMetadataConfiguration",
1466                _ => return None,
1467            });
1468        }
1469        if has("metadataTable") {
1470            return Some(match method {
1471                "GET" => "GetBucketMetadataTableConfiguration",
1472                "POST" => "CreateBucketMetadataTableConfiguration",
1473                "DELETE" => "DeleteBucketMetadataTableConfiguration",
1474                _ => return None,
1475            });
1476        }
1477        if has("metadataInventoryTable") && is_put {
1478            return Some("UpdateBucketMetadataInventoryTableConfiguration");
1479        }
1480        if has("metadataJournalTable") && is_put {
1481            return Some("UpdateBucketMetadataJournalTableConfiguration");
1482        }
1483        if has("abac") && is_put {
1484            return Some("PutBucketAbacConfiguration");
1485        }
1486        if has("renameObject") && is_put {
1487            return Some("RenameObject");
1488        }
1489        if has("object-lock") {
1490            return Some(match method {
1491                "GET" => "GetObjectLockConfiguration",
1492                "PUT" => "PutObjectLockConfiguration",
1493                _ => return None,
1494            });
1495        }
1496        if has("location") {
1497            return Some("GetBucketLocation");
1498        }
1499        if is_post && has("delete") {
1500            return Some("DeleteObjects");
1501        }
1502        if is_get && has("versions") {
1503            return Some("ListObjectVersions");
1504        }
1505    }
1506
1507    // Plain bucket/object methods.
1508    match (method, has_key) {
1509        ("GET", true) => Some("GetObject"),
1510        ("PUT", true) => {
1511            // CopyObject uses x-amz-copy-source but we don't have headers
1512            // handy here — treat both PutObject and CopyObject as PutObject
1513            // for IAM purposes; CopyObject additionally requires
1514            // s3:GetObject on the source but that's evaluated per-request
1515            // by real AWS, not on the PUT call itself.
1516            Some("PutObject")
1517        }
1518        ("DELETE", true) => Some("DeleteObject"),
1519        ("HEAD", true) => Some("HeadObject"),
1520        ("GET", false) => {
1521            if query.contains_key("list-type") {
1522                Some("ListObjectsV2")
1523            } else {
1524                Some("ListObjects")
1525            }
1526        }
1527        ("PUT", false) => Some("CreateBucket"),
1528        ("DELETE", false) => Some("DeleteBucket"),
1529        ("HEAD", false) => Some("HeadBucket"),
1530        _ => None,
1531    }
1532}
1533
1534/// Build the S3 resource ARN for an action. Returns `*` for
1535/// `ListBuckets` (account-scoped), a bucket ARN for bucket-level
1536/// configuration actions, or an object ARN for object-level actions.
1537fn s3_resource_for(action: &'static str, bucket: Option<&str>, key: Option<&str>) -> String {
1538    // Object-level actions work on `bucket/key`.
1539    const OBJECT_ACTIONS: &[&str] = &[
1540        "PutObject",
1541        "GetObject",
1542        "DeleteObject",
1543        "HeadObject",
1544        "CopyObject",
1545        "GetObjectAttributes",
1546        "RestoreObject",
1547        "PutObjectTagging",
1548        "GetObjectTagging",
1549        "DeleteObjectTagging",
1550        "PutObjectAcl",
1551        "GetObjectAcl",
1552        "PutObjectRetention",
1553        "GetObjectRetention",
1554        "PutObjectLegalHold",
1555        "GetObjectLegalHold",
1556        "CreateMultipartUpload",
1557        "UploadPart",
1558        "UploadPartCopy",
1559        "CompleteMultipartUpload",
1560        "AbortMultipartUpload",
1561        "ListParts",
1562    ];
1563    if action == "ListBuckets" {
1564        return "*".to_string();
1565    }
1566    let Some(bucket) = bucket else {
1567        return "*".to_string();
1568    };
1569    if OBJECT_ACTIONS.contains(&action) {
1570        match key {
1571            Some(k) if !k.is_empty() => Arn::s3(&format!("{bucket}/{k}")).to_string(),
1572            _ => Arn::s3(&format!("{bucket}/*")).to_string(),
1573        }
1574    } else {
1575        // Bucket-level actions (ListObjectsV2, GetBucketTagging, ...).
1576        Arn::s3(bucket).to_string()
1577    }
1578}
1579
1580// Conditional request helpers
1581
1582/// Truncate a DateTime to second-level precision (HTTP dates have no sub-second info).
1583pub(crate) fn truncate_to_seconds(dt: DateTime<Utc>) -> DateTime<Utc> {
1584    dt.with_nanosecond(0).unwrap_or(dt)
1585}
1586
1587pub(crate) fn check_get_conditionals(
1588    req: &AwsRequest,
1589    obj: &S3Object,
1590) -> Result<(), AwsServiceError> {
1591    let obj_etag = format!("\"{}\"", obj.etag);
1592    let obj_time = truncate_to_seconds(obj.last_modified);
1593
1594    // If-Match
1595    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1596        if !etag_matches(if_match, &obj_etag) {
1597            return Err(precondition_failed("If-Match"));
1598        }
1599    }
1600
1601    // If-None-Match
1602    if let Some(if_none_match) = req
1603        .headers
1604        .get("if-none-match")
1605        .and_then(|v| v.to_str().ok())
1606    {
1607        if etag_matches(if_none_match, &obj_etag) {
1608            return Err(not_modified_with_etag(&obj_etag));
1609        }
1610    }
1611
1612    // If-Unmodified-Since
1613    if let Some(since) = req
1614        .headers
1615        .get("if-unmodified-since")
1616        .and_then(|v| v.to_str().ok())
1617    {
1618        if let Some(dt) = parse_http_date(since) {
1619            if obj_time > dt {
1620                return Err(precondition_failed("If-Unmodified-Since"));
1621            }
1622        }
1623    }
1624
1625    // If-Modified-Since
1626    if let Some(since) = req
1627        .headers
1628        .get("if-modified-since")
1629        .and_then(|v| v.to_str().ok())
1630    {
1631        if let Some(dt) = parse_http_date(since) {
1632            if obj_time <= dt {
1633                return Err(not_modified());
1634            }
1635        }
1636    }
1637
1638    Ok(())
1639}
1640
1641pub(crate) fn check_head_conditionals(
1642    req: &AwsRequest,
1643    obj: &S3Object,
1644) -> Result<(), AwsServiceError> {
1645    let obj_etag = format!("\"{}\"", obj.etag);
1646    let obj_time = truncate_to_seconds(obj.last_modified);
1647
1648    // If-Match
1649    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
1650        if !etag_matches(if_match, &obj_etag) {
1651            return Err(AwsServiceError::aws_error(
1652                StatusCode::PRECONDITION_FAILED,
1653                "412",
1654                "Precondition Failed",
1655            ));
1656        }
1657    }
1658
1659    // If-None-Match
1660    if let Some(if_none_match) = req
1661        .headers
1662        .get("if-none-match")
1663        .and_then(|v| v.to_str().ok())
1664    {
1665        if etag_matches(if_none_match, &obj_etag) {
1666            return Err(not_modified_with_etag(&obj_etag));
1667        }
1668    }
1669
1670    // If-Unmodified-Since
1671    if let Some(since) = req
1672        .headers
1673        .get("if-unmodified-since")
1674        .and_then(|v| v.to_str().ok())
1675    {
1676        if let Some(dt) = parse_http_date(since) {
1677            if obj_time > dt {
1678                return Err(AwsServiceError::aws_error(
1679                    StatusCode::PRECONDITION_FAILED,
1680                    "412",
1681                    "Precondition Failed",
1682                ));
1683            }
1684        }
1685    }
1686
1687    // If-Modified-Since
1688    if let Some(since) = req
1689        .headers
1690        .get("if-modified-since")
1691        .and_then(|v| v.to_str().ok())
1692    {
1693        if let Some(dt) = parse_http_date(since) {
1694            if obj_time <= dt {
1695                return Err(not_modified());
1696            }
1697        }
1698    }
1699
1700    Ok(())
1701}
1702
1703pub(crate) fn etag_matches(condition: &str, obj_etag: &str) -> bool {
1704    let condition = condition.trim();
1705    if condition == "*" {
1706        return true;
1707    }
1708    let clean_etag = obj_etag.replace('"', "");
1709    // Split on comma to handle multi-value If-Match / If-None-Match
1710    for part in condition.split(',') {
1711        let part = part.trim().replace('"', "");
1712        if part == clean_etag {
1713            return true;
1714        }
1715    }
1716    false
1717}
1718
1719pub(crate) fn parse_http_date(s: &str) -> Option<DateTime<Utc>> {
1720    // Try RFC 2822 format: "Sat, 01 Jan 2000 00:00:00 GMT"
1721    if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
1722        return Some(dt.with_timezone(&Utc));
1723    }
1724    // Try RFC 3339
1725    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1726        return Some(dt.with_timezone(&Utc));
1727    }
1728    // Try common HTTP date format: "%a, %d %b %Y %H:%M:%S GMT"
1729    if let Ok(dt) =
1730        chrono::NaiveDateTime::parse_from_str(s.trim_end_matches(" GMT"), "%a, %d %b %Y %H:%M:%S")
1731    {
1732        return Some(dt.and_utc());
1733    }
1734    // Try ISO 8601
1735    if let Ok(dt) = s.parse::<DateTime<Utc>>() {
1736        return Some(dt);
1737    }
1738    None
1739}
1740
1741pub(crate) fn not_modified() -> AwsServiceError {
1742    AwsServiceError::aws_error(StatusCode::NOT_MODIFIED, "304", "Not Modified")
1743}
1744
1745pub(crate) fn not_modified_with_etag(etag: &str) -> AwsServiceError {
1746    AwsServiceError::aws_error_with_headers(
1747        StatusCode::NOT_MODIFIED,
1748        "304",
1749        "Not Modified",
1750        vec![("etag".to_string(), etag.to_string())],
1751    )
1752}
1753
1754pub(crate) fn precondition_failed(condition: &str) -> AwsServiceError {
1755    AwsServiceError::aws_error_with_fields(
1756        StatusCode::PRECONDITION_FAILED,
1757        "PreconditionFailed",
1758        "At least one of the pre-conditions you specified did not hold",
1759        vec![("Condition".to_string(), condition.to_string())],
1760    )
1761}
1762
1763// ACL helpers
1764
1765pub(crate) fn build_acl_xml(owner_id: &str, grants: &[AclGrant], _account_id: &str) -> String {
1766    let mut grants_xml = String::new();
1767    for g in grants {
1768        let grantee_xml = if g.grantee_type == "Group" {
1769            let uri = g.grantee_uri.as_deref().unwrap_or("");
1770            format!(
1771                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
1772                 <URI>{}</URI></Grantee>",
1773                xml_escape(uri),
1774            )
1775        } else {
1776            let id = g.grantee_id.as_deref().unwrap_or("");
1777            format!(
1778                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
1779                 <ID>{}</ID></Grantee>",
1780                xml_escape(id),
1781            )
1782        };
1783        grants_xml.push_str(&format!(
1784            "<Grant>{grantee_xml}<Permission>{}</Permission></Grant>",
1785            xml_escape(&g.permission),
1786        ));
1787    }
1788
1789    format!(
1790        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1791         <AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1792         <Owner><ID>{owner_id}</ID><DisplayName>{owner_id}</DisplayName></Owner>\
1793         <AccessControlList>{grants_xml}</AccessControlList>\
1794         </AccessControlPolicy>",
1795        owner_id = xml_escape(owner_id),
1796    )
1797}
1798
1799pub(crate) fn canned_acl_grants(acl: &str, owner_id: &str) -> Vec<AclGrant> {
1800    let owner_grant = AclGrant {
1801        grantee_type: "CanonicalUser".to_string(),
1802        grantee_id: Some(owner_id.to_string()),
1803        grantee_display_name: Some(owner_id.to_string()),
1804        grantee_uri: None,
1805        permission: "FULL_CONTROL".to_string(),
1806    };
1807    match acl {
1808        "private" => vec![owner_grant],
1809        "public-read" => vec![
1810            owner_grant,
1811            AclGrant {
1812                grantee_type: "Group".to_string(),
1813                grantee_id: None,
1814                grantee_display_name: None,
1815                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1816                permission: "READ".to_string(),
1817            },
1818        ],
1819        "public-read-write" => vec![
1820            owner_grant,
1821            AclGrant {
1822                grantee_type: "Group".to_string(),
1823                grantee_id: None,
1824                grantee_display_name: None,
1825                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1826                permission: "READ".to_string(),
1827            },
1828            AclGrant {
1829                grantee_type: "Group".to_string(),
1830                grantee_id: None,
1831                grantee_display_name: None,
1832                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
1833                permission: "WRITE".to_string(),
1834            },
1835        ],
1836        "authenticated-read" => vec![
1837            owner_grant,
1838            AclGrant {
1839                grantee_type: "Group".to_string(),
1840                grantee_id: None,
1841                grantee_display_name: None,
1842                grantee_uri: Some(
1843                    "http://acs.amazonaws.com/groups/global/AuthenticatedUsers".to_string(),
1844                ),
1845                permission: "READ".to_string(),
1846            },
1847        ],
1848        "bucket-owner-full-control" => vec![owner_grant],
1849        _ => vec![owner_grant],
1850    }
1851}
1852
1853pub(crate) fn canned_acl_grants_for_object(acl: &str, owner_id: &str) -> Vec<AclGrant> {
1854    // For objects, canned ACLs work the same way
1855    canned_acl_grants(acl, owner_id)
1856}
1857
1858pub(crate) fn parse_grant_headers(headers: &HeaderMap) -> Vec<AclGrant> {
1859    let mut grants = Vec::new();
1860    let header_permission_map = [
1861        ("x-amz-grant-read", "READ"),
1862        ("x-amz-grant-write", "WRITE"),
1863        ("x-amz-grant-read-acp", "READ_ACP"),
1864        ("x-amz-grant-write-acp", "WRITE_ACP"),
1865        ("x-amz-grant-full-control", "FULL_CONTROL"),
1866    ];
1867
1868    for (header, permission) in &header_permission_map {
1869        if let Some(value) = headers.get(*header).and_then(|v| v.to_str().ok()) {
1870            // Parse "id=xxx" or "uri=xxx" or "emailAddress=xxx"
1871            for part in value.split(',') {
1872                let part = part.trim();
1873                if let Some((key, val)) = part.split_once('=') {
1874                    let val = val.trim().trim_matches('"');
1875                    let key = key.trim().to_lowercase();
1876                    match key.as_str() {
1877                        "id" => {
1878                            grants.push(AclGrant {
1879                                grantee_type: "CanonicalUser".to_string(),
1880                                grantee_id: Some(val.to_string()),
1881                                grantee_display_name: Some(val.to_string()),
1882                                grantee_uri: None,
1883                                permission: permission.to_string(),
1884                            });
1885                        }
1886                        "uri" | "url" => {
1887                            grants.push(AclGrant {
1888                                grantee_type: "Group".to_string(),
1889                                grantee_id: None,
1890                                grantee_display_name: None,
1891                                grantee_uri: Some(val.to_string()),
1892                                permission: permission.to_string(),
1893                            });
1894                        }
1895                        _ => {}
1896                    }
1897                }
1898            }
1899        }
1900    }
1901    grants
1902}
1903
1904pub(crate) fn parse_acl_xml(xml: &str) -> Result<Vec<AclGrant>, AwsServiceError> {
1905    // Check for Owner presence
1906    if xml.contains("<AccessControlPolicy") && !xml.contains("<Owner>") {
1907        return Err(AwsServiceError::aws_error(
1908            StatusCode::BAD_REQUEST,
1909            "MalformedACLError",
1910            "The XML you provided was not well-formed or did not validate against our published schema",
1911        ));
1912    }
1913
1914    let valid_permissions = ["READ", "WRITE", "READ_ACP", "WRITE_ACP", "FULL_CONTROL"];
1915
1916    let mut grants = Vec::new();
1917    let mut remaining = xml;
1918    while let Some(start) = remaining.find("<Grant>") {
1919        let after = &remaining[start + 7..];
1920        if let Some(end) = after.find("</Grant>") {
1921            let grant_body = &after[..end];
1922
1923            // Extract permission
1924            let permission = extract_xml_value(grant_body, "Permission").unwrap_or_default();
1925            if !valid_permissions.contains(&permission.as_str()) {
1926                return Err(AwsServiceError::aws_error(
1927                    StatusCode::BAD_REQUEST,
1928                    "MalformedACLError",
1929                    "The XML you provided was not well-formed or did not validate against our published schema",
1930                ));
1931            }
1932
1933            // Determine grantee type
1934            if grant_body.contains("xsi:type=\"Group\"") || grant_body.contains("<URI>") {
1935                let uri = extract_xml_value(grant_body, "URI").unwrap_or_default();
1936                grants.push(AclGrant {
1937                    grantee_type: "Group".to_string(),
1938                    grantee_id: None,
1939                    grantee_display_name: None,
1940                    grantee_uri: Some(uri),
1941                    permission,
1942                });
1943            } else {
1944                let id = extract_xml_value(grant_body, "ID").unwrap_or_default();
1945                let display =
1946                    extract_xml_value(grant_body, "DisplayName").unwrap_or_else(|| id.clone());
1947                grants.push(AclGrant {
1948                    grantee_type: "CanonicalUser".to_string(),
1949                    grantee_id: Some(id),
1950                    grantee_display_name: Some(display),
1951                    grantee_uri: None,
1952                    permission,
1953                });
1954            }
1955
1956            remaining = &after[end + 8..];
1957        } else {
1958            break;
1959        }
1960    }
1961    Ok(grants)
1962}
1963
1964// Range helpers
1965
1966pub(crate) enum RangeResult {
1967    Satisfiable { start: usize, end: usize },
1968    NotSatisfiable,
1969    Ignored,
1970}
1971
1972pub(crate) fn parse_range_header(range_str: &str, total_size: usize) -> Option<RangeResult> {
1973    let range_str = range_str.strip_prefix("bytes=")?;
1974    let (start_str, end_str) = range_str.split_once('-')?;
1975    if start_str.is_empty() {
1976        let suffix_len: usize = end_str.parse().ok()?;
1977        if suffix_len == 0 || total_size == 0 {
1978            return Some(RangeResult::NotSatisfiable);
1979        }
1980        let start = total_size.saturating_sub(suffix_len);
1981        Some(RangeResult::Satisfiable {
1982            start,
1983            end: total_size - 1,
1984        })
1985    } else {
1986        let start: usize = start_str.parse().ok()?;
1987        if start >= total_size {
1988            return Some(RangeResult::NotSatisfiable);
1989        }
1990        let end = if end_str.is_empty() {
1991            total_size - 1
1992        } else {
1993            let e: usize = end_str.parse().ok()?;
1994            if e < start {
1995                return Some(RangeResult::Ignored);
1996            }
1997            std::cmp::min(e, total_size - 1)
1998        };
1999        Some(RangeResult::Satisfiable { start, end })
2000    }
2001}
2002
2003// Helpers
2004
2005/// S3 XML response with `application/xml` content type (unlike Query protocol's `text/xml`).
2006pub(crate) fn s3_xml(status: StatusCode, body: impl Into<Bytes>) -> AwsResponse {
2007    AwsResponse {
2008        status,
2009        content_type: "application/xml".to_string(),
2010        body: body.into().into(),
2011        headers: HeaderMap::new(),
2012    }
2013}
2014
2015pub(crate) fn empty_response(status: StatusCode) -> AwsResponse {
2016    AwsResponse {
2017        status,
2018        content_type: "application/xml".to_string(),
2019        body: Bytes::new().into(),
2020        headers: HeaderMap::new(),
2021    }
2022}
2023
2024/// Returns true when the object is stored in a "cold" storage class (GLACIER, DEEP_ARCHIVE)
2025/// and has NOT been restored (or restore is still in progress).
2026pub(crate) fn is_frozen(obj: &S3Object) -> bool {
2027    matches!(obj.storage_class.as_str(), "GLACIER" | "DEEP_ARCHIVE")
2028        && obj.restore_ongoing != Some(false)
2029}
2030
2031pub(crate) fn no_such_bucket(bucket: &str) -> AwsServiceError {
2032    AwsServiceError::aws_error_with_fields(
2033        StatusCode::NOT_FOUND,
2034        "NoSuchBucket",
2035        "The specified bucket does not exist",
2036        vec![("BucketName".to_string(), bucket.to_string())],
2037    )
2038}
2039
2040pub(crate) fn no_such_key(key: &str) -> AwsServiceError {
2041    AwsServiceError::aws_error_with_fields(
2042        StatusCode::NOT_FOUND,
2043        "NoSuchKey",
2044        "The specified key does not exist.",
2045        vec![("Key".to_string(), key.to_string())],
2046    )
2047}
2048
2049pub(crate) fn no_such_upload(upload_id: &str) -> AwsServiceError {
2050    AwsServiceError::aws_error_with_fields(
2051        StatusCode::NOT_FOUND,
2052        "NoSuchUpload",
2053        "The specified upload does not exist. The upload ID may be invalid, \
2054         or the upload may have been aborted or completed.",
2055        vec![("UploadId".to_string(), upload_id.to_string())],
2056    )
2057}
2058
2059pub(crate) fn no_such_key_with_detail(key: &str) -> AwsServiceError {
2060    AwsServiceError::aws_error_with_fields(
2061        StatusCode::NOT_FOUND,
2062        "NoSuchKey",
2063        "The specified key does not exist.",
2064        vec![("Key".to_string(), key.to_string())],
2065    )
2066}
2067
2068pub(crate) fn compute_md5(data: &[u8]) -> String {
2069    let digest = Md5::digest(data);
2070    format!("{:x}", digest)
2071}
2072
2073pub(crate) fn compute_checksum(algorithm: &str, data: &[u8]) -> String {
2074    match algorithm {
2075        "CRC32" => {
2076            let crc = crc32fast::hash(data);
2077            BASE64.encode(crc.to_be_bytes())
2078        }
2079        "SHA1" => {
2080            use sha1::Digest as _;
2081            let hash = sha1::Sha1::digest(data);
2082            BASE64.encode(hash)
2083        }
2084        "SHA256" => {
2085            use sha2::Digest as _;
2086            let hash = sha2::Sha256::digest(data);
2087            BASE64.encode(hash)
2088        }
2089        _ => String::new(),
2090    }
2091}
2092
2093/// Streaming variant of [`compute_checksum`] for spool files. Reads
2094/// the file in 1 MiB chunks and feeds each chunk into the hasher so a
2095/// 1 GiB upload computes its CRC32 / SHA-1 / SHA-256 in constant
2096/// memory rather than via `tokio::fs::read` (which would allocate the
2097/// whole file as one buffer).
2098pub(crate) async fn compute_checksum_streaming(
2099    algorithm: &str,
2100    path: &std::path::Path,
2101) -> Result<String, std::io::Error> {
2102    use tokio::io::AsyncReadExt;
2103    let mut file = tokio::fs::File::open(path).await?;
2104    let mut buf = vec![0u8; 1024 * 1024];
2105    match algorithm {
2106        "CRC32" => {
2107            let mut hasher = crc32fast::Hasher::new();
2108            loop {
2109                let n = file.read(&mut buf).await?;
2110                if n == 0 {
2111                    break;
2112                }
2113                hasher.update(&buf[..n]);
2114            }
2115            Ok(BASE64.encode(hasher.finalize().to_be_bytes()))
2116        }
2117        "CRC32C" => {
2118            let mut crc: u32 = 0;
2119            loop {
2120                let n = file.read(&mut buf).await?;
2121                if n == 0 {
2122                    break;
2123                }
2124                crc = crc32c::crc32c_append(crc, &buf[..n]);
2125            }
2126            Ok(BASE64.encode(crc.to_be_bytes()))
2127        }
2128        "CRC64NVME" => {
2129            let mut hasher = crc64fast_nvme::Digest::new();
2130            loop {
2131                let n = file.read(&mut buf).await?;
2132                if n == 0 {
2133                    break;
2134                }
2135                hasher.write(&buf[..n]);
2136            }
2137            Ok(BASE64.encode(hasher.sum64().to_be_bytes()))
2138        }
2139        "SHA1" => {
2140            use sha1::Digest as _;
2141            let mut hasher = sha1::Sha1::new();
2142            loop {
2143                let n = file.read(&mut buf).await?;
2144                if n == 0 {
2145                    break;
2146                }
2147                hasher.update(&buf[..n]);
2148            }
2149            Ok(BASE64.encode(hasher.finalize()))
2150        }
2151        "SHA256" => {
2152            use sha2::Digest as _;
2153            let mut hasher = sha2::Sha256::new();
2154            loop {
2155                let n = file.read(&mut buf).await?;
2156                if n == 0 {
2157                    break;
2158                }
2159                hasher.update(&buf[..n]);
2160            }
2161            Ok(BASE64.encode(hasher.finalize()))
2162        }
2163        _ => Ok(String::new()),
2164    }
2165}
2166
2167pub(crate) fn url_encode_s3_key(s: &str) -> String {
2168    let mut out = String::with_capacity(s.len() * 2);
2169    for byte in s.bytes() {
2170        match byte {
2171            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
2172                out.push(byte as char);
2173            }
2174            _ => {
2175                out.push_str(&format!("%{:02X}", byte));
2176            }
2177        }
2178    }
2179    out
2180}
2181
2182pub(crate) use fakecloud_aws::xml::xml_escape;
2183
2184pub(crate) fn extract_user_metadata(
2185    headers: &HeaderMap,
2186) -> std::collections::BTreeMap<String, String> {
2187    let mut meta = std::collections::BTreeMap::new();
2188    for (name, value) in headers {
2189        if let Some(key) = name.as_str().strip_prefix("x-amz-meta-") {
2190            if let Ok(v) = value.to_str() {
2191                meta.insert(key.to_string(), v.to_string());
2192            }
2193        }
2194    }
2195    meta
2196}
2197
2198pub(crate) fn is_valid_storage_class(class: &str) -> bool {
2199    matches!(
2200        class,
2201        "STANDARD"
2202            | "REDUCED_REDUNDANCY"
2203            | "STANDARD_IA"
2204            | "ONEZONE_IA"
2205            | "INTELLIGENT_TIERING"
2206            | "GLACIER"
2207            | "DEEP_ARCHIVE"
2208            | "GLACIER_IR"
2209            | "OUTPOSTS"
2210            | "SNOW"
2211            | "EXPRESS_ONEZONE"
2212    )
2213}
2214
2215pub(crate) fn is_valid_bucket_name(name: &str) -> bool {
2216    if name.len() < 3 || name.len() > 63 {
2217        return false;
2218    }
2219    // Must start and end with alphanumeric
2220    let bytes = name.as_bytes();
2221    if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
2222        return false;
2223    }
2224    // Only lowercase letters, digits, hyphens, dots (also allow underscores for compatibility)
2225    name.chars()
2226        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '_')
2227}
2228
2229pub(crate) fn is_valid_region(region: &str) -> bool {
2230    // Basic validation: region should match pattern like us-east-1, eu-west-2, etc.
2231    let valid_regions = [
2232        "us-east-1",
2233        "us-east-2",
2234        "us-west-1",
2235        "us-west-2",
2236        "af-south-1",
2237        "ap-east-1",
2238        "ap-south-1",
2239        "ap-south-2",
2240        "ap-southeast-1",
2241        "ap-southeast-2",
2242        "ap-southeast-3",
2243        "ap-southeast-4",
2244        "ap-northeast-1",
2245        "ap-northeast-2",
2246        "ap-northeast-3",
2247        "ca-central-1",
2248        "ca-west-1",
2249        "eu-central-1",
2250        "eu-central-2",
2251        "eu-west-1",
2252        "eu-west-2",
2253        "eu-west-3",
2254        "eu-south-1",
2255        "eu-south-2",
2256        "eu-north-1",
2257        "il-central-1",
2258        "me-south-1",
2259        "me-central-1",
2260        "sa-east-1",
2261        "cn-north-1",
2262        "cn-northwest-1",
2263        "us-gov-east-1",
2264        "us-gov-east-2",
2265        "us-gov-west-1",
2266        "us-iso-east-1",
2267        "us-iso-west-1",
2268        "us-isob-east-1",
2269        "us-isof-south-1",
2270    ];
2271    valid_regions.contains(&region)
2272}
2273
2274pub(crate) fn resolve_object<'a>(
2275    b: &'a S3Bucket,
2276    key: &str,
2277    version_id: Option<&String>,
2278) -> Result<&'a S3Object, AwsServiceError> {
2279    if let Some(vid) = version_id {
2280        // "null" version ID refers to an object with no version_id (pre-versioning)
2281        if vid == "null" {
2282            // Check versions for a pre-versioning object (version_id == None or Some("null"))
2283            if let Some(versions) = b.object_versions.get(key) {
2284                if let Some(obj) = versions
2285                    .iter()
2286                    .find(|o| o.version_id.is_none() || o.version_id.as_deref() == Some("null"))
2287                {
2288                    return Ok(obj);
2289                }
2290            }
2291            // Also check current object if it has no version_id
2292            if let Some(obj) = b.objects.get(key) {
2293                if obj.version_id.is_none() || obj.version_id.as_deref() == Some("null") {
2294                    return Ok(obj);
2295                }
2296            }
2297        } else {
2298            // When a specific versionId is requested, check versions first
2299            if let Some(versions) = b.object_versions.get(key) {
2300                if let Some(obj) = versions
2301                    .iter()
2302                    .find(|o| o.version_id.as_deref() == Some(vid.as_str()))
2303                {
2304                    return Ok(obj);
2305                }
2306            }
2307            // Also check current object
2308            if let Some(obj) = b.objects.get(key) {
2309                if obj.version_id.as_deref() == Some(vid.as_str()) {
2310                    return Ok(obj);
2311                }
2312            }
2313        }
2314        // For versioned buckets, return NoSuchVersion; for non-versioned, return 400
2315        if b.versioning.is_some() {
2316            Err(AwsServiceError::aws_error_with_fields(
2317                StatusCode::NOT_FOUND,
2318                "NoSuchVersion",
2319                "The specified version does not exist.",
2320                vec![
2321                    ("Key".to_string(), key.to_string()),
2322                    ("VersionId".to_string(), vid.to_string()),
2323                ],
2324            ))
2325        } else {
2326            Err(AwsServiceError::aws_error(
2327                StatusCode::BAD_REQUEST,
2328                "InvalidArgument",
2329                "Invalid version id specified",
2330            ))
2331        }
2332    } else {
2333        b.objects.get(key).ok_or_else(|| no_such_key(key))
2334    }
2335}
2336
2337pub(crate) fn make_delete_marker(key: &str, dm_id: &str) -> S3Object {
2338    S3Object {
2339        key: key.to_string(),
2340        last_modified: Utc::now(),
2341        storage_class: "STANDARD".to_string(),
2342        version_id: Some(dm_id.to_string()),
2343        is_delete_marker: true,
2344        ..Default::default()
2345    }
2346}
2347
2348/// Represents an object to delete in a batch delete request.
2349pub(crate) struct DeleteObjectEntry {
2350    key: String,
2351    version_id: Option<String>,
2352}
2353
2354pub(crate) fn parse_delete_objects_xml(xml: &str) -> Vec<DeleteObjectEntry> {
2355    let mut entries = Vec::new();
2356    let mut remaining = xml;
2357    while let Some(obj_start) = remaining.find("<Object>") {
2358        let after = &remaining[obj_start + 8..];
2359        if let Some(obj_end) = after.find("</Object>") {
2360            let obj_body = &after[..obj_end];
2361            let key = extract_xml_value(obj_body, "Key");
2362            let version_id = extract_xml_value(obj_body, "VersionId");
2363            if let Some(k) = key {
2364                entries.push(DeleteObjectEntry { key: k, version_id });
2365            }
2366            remaining = &after[obj_end + 9..];
2367        } else {
2368            break;
2369        }
2370    }
2371    entries
2372}
2373
2374/// Returns true when the request body's top-level <Quiet> element is "true".
2375/// AWS docs: when Quiet=true, only failed deletes are emitted.
2376pub(crate) fn parse_delete_objects_quiet(xml: &str) -> bool {
2377    extract_xml_value(xml, "Quiet")
2378        .map(|v| v.eq_ignore_ascii_case("true"))
2379        .unwrap_or(false)
2380}
2381
2382/// Minimal XML parser for `<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag>...`.
2383/// Returns a Vec to preserve insertion order and detect duplicates.
2384pub(crate) fn parse_tagging_xml(xml: &str) -> Vec<(String, String)> {
2385    let mut tags = Vec::new();
2386    let mut remaining = xml;
2387    while let Some(tag_start) = remaining.find("<Tag>") {
2388        let after = &remaining[tag_start + 5..];
2389        if let Some(tag_end) = after.find("</Tag>") {
2390            let tag_body = &after[..tag_end];
2391            let key = extract_xml_value(tag_body, "Key");
2392            let value = extract_xml_value(tag_body, "Value");
2393            if let (Some(k), Some(v)) = (key, value) {
2394                tags.push((k, v));
2395            }
2396            remaining = &after[tag_end + 6..];
2397        } else {
2398            break;
2399        }
2400    }
2401    tags
2402}
2403
2404pub(crate) fn validate_tags(tags: &[(String, String)]) -> Result<(), AwsServiceError> {
2405    // Check for duplicate keys
2406    let mut seen = std::collections::HashSet::new();
2407    for (k, _) in tags {
2408        if !seen.insert(k.as_str()) {
2409            return Err(AwsServiceError::aws_error(
2410                StatusCode::BAD_REQUEST,
2411                "InvalidTag",
2412                "Cannot provide multiple Tags with the same key",
2413            ));
2414        }
2415        // Check for aws: prefix
2416        if k.starts_with("aws:") {
2417            return Err(AwsServiceError::aws_error(
2418                StatusCode::BAD_REQUEST,
2419                "InvalidTag",
2420                "System tags cannot be added/updated by requester",
2421            ));
2422        }
2423    }
2424    Ok(())
2425}
2426
2427pub(crate) fn extract_xml_value(xml: &str, tag: &str) -> Option<String> {
2428    // Handle self-closing tags like <Value /> or <Value/>
2429    let self_closing1 = format!("<{tag} />");
2430    let self_closing2 = format!("<{tag}/>");
2431    if xml.contains(&self_closing1) || xml.contains(&self_closing2) {
2432        // Check if the self-closing tag appears before any open+close pair
2433        let self_pos = xml
2434            .find(&self_closing1)
2435            .or_else(|| xml.find(&self_closing2));
2436        let open = format!("<{tag}>");
2437        let open_pos = xml.find(&open);
2438        match (self_pos, open_pos) {
2439            (Some(sp), Some(op)) if sp < op => return Some(String::new()),
2440            (Some(_), None) => return Some(String::new()),
2441            _ => {}
2442        }
2443    }
2444
2445    let open = format!("<{tag}>");
2446    let close = format!("</{tag}>");
2447    let start = xml.find(&open)? + open.len();
2448    // Search for the closing tag AFTER the opening one so a malformed body
2449    // (closing tag before opening) can't make end < start and panic the
2450    // slice (bug-audit 2026-05-28, 2.2).
2451    let end = xml[start..].find(&close)? + start;
2452    Some(xml[start..end].to_string())
2453}
2454
2455/// Parse the CompleteMultipartUpload XML body into (part_number, etag) pairs.
2456pub(crate) fn parse_complete_multipart_xml(xml: &str) -> Vec<(u32, String)> {
2457    let mut parts = Vec::new();
2458    let mut remaining = xml;
2459    while let Some(part_start) = remaining.find("<Part>") {
2460        let after = &remaining[part_start + 6..];
2461        if let Some(part_end) = after.find("</Part>") {
2462            let part_body = &after[..part_end];
2463            let part_num =
2464                extract_xml_value(part_body, "PartNumber").and_then(|s| s.parse::<u32>().ok());
2465            let etag = extract_xml_value(part_body, "ETag")
2466                .map(|s| s.replace("&quot;", "").replace('"', ""));
2467            if let (Some(num), Some(e)) = (part_num, etag) {
2468                parts.push((num, e));
2469            }
2470            remaining = &after[part_end + 7..];
2471        } else {
2472            break;
2473        }
2474    }
2475    parts
2476}
2477
2478pub(crate) fn parse_url_encoded_tags(s: &str) -> Vec<(String, String)> {
2479    let mut tags = Vec::new();
2480    for pair in s.split('&') {
2481        if pair.is_empty() {
2482            continue;
2483        }
2484        let (key, value) = match pair.find('=') {
2485            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
2486            None => (pair, ""),
2487        };
2488        tags.push((
2489            percent_encoding::percent_decode_str(key)
2490                .decode_utf8_lossy()
2491                .to_string(),
2492            percent_encoding::percent_decode_str(value)
2493                .decode_utf8_lossy()
2494                .to_string(),
2495        ));
2496    }
2497    tags
2498}
2499
2500/// Validate lifecycle configuration XML. Returns MalformedXML on invalid configs.
2501pub(crate) fn validate_lifecycle_xml(xml: &str) -> Result<(), AwsServiceError> {
2502    let malformed = || {
2503        AwsServiceError::aws_error(
2504            StatusCode::BAD_REQUEST,
2505            "MalformedXML",
2506            "The XML you provided was not well-formed or did not validate against our published schema",
2507        )
2508    };
2509
2510    let mut remaining = xml;
2511    while let Some(rule_start) = remaining.find("<Rule>") {
2512        let after = &remaining[rule_start + 6..];
2513        if let Some(rule_end) = after.find("</Rule>") {
2514            let rule_body = &after[..rule_end];
2515
2516            // Must have Filter or Prefix
2517            let has_filter = rule_body.contains("<Filter>")
2518                || rule_body.contains("<Filter/>")
2519                || rule_body.contains("<Filter />");
2520
2521            // Check for <Prefix> at rule level (outside of <Filter>...</Filter>)
2522            let has_prefix_outside_filter = {
2523                if !rule_body.contains("<Prefix") {
2524                    false
2525                } else if !has_filter {
2526                    true // No filter means any Prefix is at rule level
2527                } else {
2528                    // Remove the Filter block and check if Prefix remains
2529                    let mut stripped = rule_body.to_string();
2530                    // Remove <Filter>...</Filter> or self-closing variants
2531                    if let Some(fs) = stripped.find("<Filter") {
2532                        if let Some(fe) = stripped.find("</Filter>") {
2533                            stripped = format!("{}{}", &stripped[..fs], &stripped[fe + 9..]);
2534                        }
2535                    }
2536                    stripped.contains("<Prefix")
2537                }
2538            };
2539
2540            if !has_filter && !has_prefix_outside_filter {
2541                return Err(malformed());
2542            }
2543            // Can't have both Filter and rule-level Prefix
2544            if has_filter && has_prefix_outside_filter {
2545                return Err(malformed());
2546            }
2547
2548            // Expiration: if has ExpiredObjectDeleteMarker, cannot also have Days or Date
2549            // (only check within <Expiration> block)
2550            if let Some(exp_start) = rule_body.find("<Expiration>") {
2551                if let Some(exp_end) = rule_body[exp_start..].find("</Expiration>") {
2552                    let exp_body = &rule_body[exp_start..exp_start + exp_end];
2553                    if exp_body.contains("<ExpiredObjectDeleteMarker>")
2554                        && (exp_body.contains("<Days>") || exp_body.contains("<Date>"))
2555                    {
2556                        return Err(malformed());
2557                    }
2558                }
2559            }
2560
2561            // Filter validation
2562            if has_filter {
2563                if let Some(fs) = rule_body.find("<Filter>") {
2564                    if let Some(fe) = rule_body.find("</Filter>") {
2565                        let filter_body = &rule_body[fs + 8..fe];
2566                        let has_prefix_in_filter = filter_body.contains("<Prefix");
2567                        let has_tag_in_filter = filter_body.contains("<Tag>");
2568                        let has_and_in_filter = filter_body.contains("<And>");
2569                        // Can't have both Prefix and Tag without And
2570                        if has_prefix_in_filter && has_tag_in_filter && !has_and_in_filter {
2571                            return Err(malformed());
2572                        }
2573                        // Can't have Tag and And simultaneously at the Filter level
2574                        if has_tag_in_filter && has_and_in_filter {
2575                            // Check if the <Tag> is outside <And>
2576                            let and_start = filter_body.find("<And>").unwrap_or(0);
2577                            let tag_pos = filter_body.find("<Tag>").unwrap_or(0);
2578                            if tag_pos < and_start {
2579                                return Err(malformed());
2580                            }
2581                        }
2582                    }
2583                }
2584            }
2585
2586            // NoncurrentVersionTransition must have NoncurrentDays and StorageClass
2587            if rule_body.contains("<NoncurrentVersionTransition>") {
2588                let mut nvt_remaining = rule_body;
2589                while let Some(nvt_start) = nvt_remaining.find("<NoncurrentVersionTransition>") {
2590                    let nvt_after = &nvt_remaining[nvt_start + 29..];
2591                    if let Some(nvt_end) = nvt_after.find("</NoncurrentVersionTransition>") {
2592                        let nvt_body = &nvt_after[..nvt_end];
2593                        if !nvt_body.contains("<NoncurrentDays>") {
2594                            return Err(malformed());
2595                        }
2596                        if !nvt_body.contains("<StorageClass>") {
2597                            return Err(malformed());
2598                        }
2599                        nvt_remaining = &nvt_after[nvt_end + 30..];
2600                    } else {
2601                        break;
2602                    }
2603                }
2604            }
2605
2606            remaining = &after[rule_end + 7..];
2607        } else {
2608            break;
2609        }
2610    }
2611
2612    Ok(())
2613}
2614
2615/// Parsed CORS rule from bucket configuration XML.
2616pub(crate) struct CorsRule {
2617    allowed_origins: Vec<String>,
2618    allowed_methods: Vec<String>,
2619    allowed_headers: Vec<String>,
2620    expose_headers: Vec<String>,
2621    max_age_seconds: Option<u32>,
2622}
2623
2624/// Parse CORS configuration XML into rules.
2625pub(crate) fn parse_cors_config(xml: &str) -> Vec<CorsRule> {
2626    let mut rules = Vec::new();
2627    let mut remaining = xml;
2628    while let Some(start) = remaining.find("<CORSRule>") {
2629        let after = &remaining[start + 10..];
2630        if let Some(end) = after.find("</CORSRule>") {
2631            let block = &after[..end];
2632            let allowed_origins = extract_all_xml_values(block, "AllowedOrigin");
2633            let allowed_methods = extract_all_xml_values(block, "AllowedMethod");
2634            let allowed_headers = extract_all_xml_values(block, "AllowedHeader");
2635            let expose_headers = extract_all_xml_values(block, "ExposeHeader");
2636            let max_age_seconds =
2637                extract_xml_value(block, "MaxAgeSeconds").and_then(|s| s.parse().ok());
2638            rules.push(CorsRule {
2639                allowed_origins,
2640                allowed_methods,
2641                allowed_headers,
2642                expose_headers,
2643                max_age_seconds,
2644            });
2645            remaining = &after[end + 11..];
2646        } else {
2647            break;
2648        }
2649    }
2650    rules
2651}
2652
2653/// Match an origin against a CORS allowed origin pattern (supports "*" wildcard).
2654pub(crate) fn origin_matches(origin: &str, pattern: &str) -> bool {
2655    if pattern == "*" {
2656        return true;
2657    }
2658    // Simple wildcard: *.example.com
2659    if let Some(suffix) = pattern.strip_prefix('*') {
2660        return origin.ends_with(suffix);
2661    }
2662    origin == pattern
2663}
2664
2665/// Find the matching CORS rule for a given origin and method.
2666pub(crate) fn find_cors_rule<'a>(
2667    rules: &'a [CorsRule],
2668    origin: &str,
2669    method: Option<&str>,
2670) -> Option<&'a CorsRule> {
2671    rules.iter().find(|rule| {
2672        let origin_ok = rule
2673            .allowed_origins
2674            .iter()
2675            .any(|o| origin_matches(origin, o));
2676        let method_ok = match method {
2677            Some(m) => rule.allowed_methods.iter().any(|am| am == m),
2678            None => true,
2679        };
2680        origin_ok && method_ok
2681    })
2682}
2683
2684/// Check if an object is locked (retention or legal hold) and should block mutation.
2685/// Returns an error string if locked, None if allowed.
2686pub(crate) fn check_object_lock_for_overwrite(
2687    obj: &S3Object,
2688    req: &AwsRequest,
2689) -> Option<&'static str> {
2690    // Legal hold blocks overwrite
2691    if obj.lock_legal_hold.as_deref() == Some("ON") {
2692        return Some("AccessDenied");
2693    }
2694    // Retention check
2695    if let (Some(mode), Some(until)) = (&obj.lock_mode, &obj.lock_retain_until) {
2696        if *until > Utc::now() {
2697            if mode == "COMPLIANCE" {
2698                return Some("AccessDenied");
2699            }
2700            if mode == "GOVERNANCE" {
2701                let bypass = req
2702                    .headers
2703                    .get("x-amz-bypass-governance-retention")
2704                    .and_then(|v| v.to_str().ok())
2705                    .map(|s| s.eq_ignore_ascii_case("true"))
2706                    .unwrap_or(false);
2707                if !bypass {
2708                    return Some("AccessDenied");
2709                }
2710            }
2711        }
2712    }
2713    None
2714}
2715
2716#[cfg(test)]
2717mod tests;
2718
2719#[cfg(test)]
2720mod extract_xml_value_tests {
2721    use super::extract_xml_value;
2722
2723    #[test]
2724    fn returns_inner_value() {
2725        assert_eq!(
2726            extract_xml_value("<Root><Key>value</Key></Root>", "Key"),
2727            Some("value".to_string())
2728        );
2729    }
2730
2731    #[test]
2732    fn missing_tag_is_none() {
2733        assert_eq!(extract_xml_value("<Root></Root>", "Key"), None);
2734    }
2735
2736    // bug-audit 2026-05-28, 2.2: a closing tag before the opening one used to
2737    // slice with end < start and panic; must return None instead.
2738    #[test]
2739    fn close_before_open_does_not_panic() {
2740        assert_eq!(extract_xml_value("</Key>oops<Key>value", "Key"), None);
2741    }
2742
2743    #[test]
2744    fn open_without_close_is_none() {
2745        assert_eq!(extract_xml_value("<Key>value", "Key"), None);
2746    }
2747}