Skip to main content

fakecloud_s3/service/
mod.rs

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