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
676// Conditional request helpers
677
678/// Truncate a DateTime to second-level precision (HTTP dates have no sub-second info).
679pub(crate) fn truncate_to_seconds(dt: DateTime<Utc>) -> DateTime<Utc> {
680    dt.with_nanosecond(0).unwrap_or(dt)
681}
682
683pub(crate) fn check_get_conditionals(
684    req: &AwsRequest,
685    obj: &S3Object,
686) -> Result<(), AwsServiceError> {
687    let obj_etag = format!("\"{}\"", obj.etag);
688    let obj_time = truncate_to_seconds(obj.last_modified);
689
690    // If-Match
691    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
692        if !etag_matches(if_match, &obj_etag) {
693            return Err(precondition_failed("If-Match"));
694        }
695    }
696
697    // If-None-Match
698    if let Some(if_none_match) = req
699        .headers
700        .get("if-none-match")
701        .and_then(|v| v.to_str().ok())
702    {
703        if etag_matches(if_none_match, &obj_etag) {
704            return Err(not_modified_with_etag(&obj_etag));
705        }
706    }
707
708    // If-Unmodified-Since
709    if let Some(since) = req
710        .headers
711        .get("if-unmodified-since")
712        .and_then(|v| v.to_str().ok())
713    {
714        if let Some(dt) = parse_http_date(since) {
715            if obj_time > dt {
716                return Err(precondition_failed("If-Unmodified-Since"));
717            }
718        }
719    }
720
721    // If-Modified-Since
722    if let Some(since) = req
723        .headers
724        .get("if-modified-since")
725        .and_then(|v| v.to_str().ok())
726    {
727        if let Some(dt) = parse_http_date(since) {
728            if obj_time <= dt {
729                return Err(not_modified());
730            }
731        }
732    }
733
734    Ok(())
735}
736
737pub(crate) fn check_head_conditionals(
738    req: &AwsRequest,
739    obj: &S3Object,
740) -> Result<(), AwsServiceError> {
741    let obj_etag = format!("\"{}\"", obj.etag);
742    let obj_time = truncate_to_seconds(obj.last_modified);
743
744    // If-Match
745    if let Some(if_match) = req.headers.get("if-match").and_then(|v| v.to_str().ok()) {
746        if !etag_matches(if_match, &obj_etag) {
747            return Err(AwsServiceError::aws_error(
748                StatusCode::PRECONDITION_FAILED,
749                "412",
750                "Precondition Failed",
751            ));
752        }
753    }
754
755    // If-None-Match
756    if let Some(if_none_match) = req
757        .headers
758        .get("if-none-match")
759        .and_then(|v| v.to_str().ok())
760    {
761        if etag_matches(if_none_match, &obj_etag) {
762            return Err(not_modified_with_etag(&obj_etag));
763        }
764    }
765
766    // If-Unmodified-Since
767    if let Some(since) = req
768        .headers
769        .get("if-unmodified-since")
770        .and_then(|v| v.to_str().ok())
771    {
772        if let Some(dt) = parse_http_date(since) {
773            if obj_time > dt {
774                return Err(AwsServiceError::aws_error(
775                    StatusCode::PRECONDITION_FAILED,
776                    "412",
777                    "Precondition Failed",
778                ));
779            }
780        }
781    }
782
783    // If-Modified-Since
784    if let Some(since) = req
785        .headers
786        .get("if-modified-since")
787        .and_then(|v| v.to_str().ok())
788    {
789        if let Some(dt) = parse_http_date(since) {
790            if obj_time <= dt {
791                return Err(not_modified());
792            }
793        }
794    }
795
796    Ok(())
797}
798
799pub(crate) fn etag_matches(condition: &str, obj_etag: &str) -> bool {
800    let condition = condition.trim();
801    if condition == "*" {
802        return true;
803    }
804    let clean_etag = obj_etag.replace('"', "");
805    // Split on comma to handle multi-value If-Match / If-None-Match
806    for part in condition.split(',') {
807        let part = part.trim().replace('"', "");
808        if part == clean_etag {
809            return true;
810        }
811    }
812    false
813}
814
815pub(crate) fn parse_http_date(s: &str) -> Option<DateTime<Utc>> {
816    // Try RFC 2822 format: "Sat, 01 Jan 2000 00:00:00 GMT"
817    if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
818        return Some(dt.with_timezone(&Utc));
819    }
820    // Try RFC 3339
821    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
822        return Some(dt.with_timezone(&Utc));
823    }
824    // Try common HTTP date format: "%a, %d %b %Y %H:%M:%S GMT"
825    if let Ok(dt) =
826        chrono::NaiveDateTime::parse_from_str(s.trim_end_matches(" GMT"), "%a, %d %b %Y %H:%M:%S")
827    {
828        return Some(dt.and_utc());
829    }
830    // Try ISO 8601
831    if let Ok(dt) = s.parse::<DateTime<Utc>>() {
832        return Some(dt);
833    }
834    None
835}
836
837pub(crate) fn not_modified() -> AwsServiceError {
838    AwsServiceError::aws_error(StatusCode::NOT_MODIFIED, "304", "Not Modified")
839}
840
841pub(crate) fn not_modified_with_etag(etag: &str) -> AwsServiceError {
842    AwsServiceError::aws_error_with_headers(
843        StatusCode::NOT_MODIFIED,
844        "304",
845        "Not Modified",
846        vec![("etag".to_string(), etag.to_string())],
847    )
848}
849
850pub(crate) fn precondition_failed(condition: &str) -> AwsServiceError {
851    AwsServiceError::aws_error_with_fields(
852        StatusCode::PRECONDITION_FAILED,
853        "PreconditionFailed",
854        "At least one of the pre-conditions you specified did not hold",
855        vec![("Condition".to_string(), condition.to_string())],
856    )
857}
858
859// ACL helpers
860
861pub(crate) fn build_acl_xml(owner_id: &str, grants: &[AclGrant], _account_id: &str) -> String {
862    let mut grants_xml = String::new();
863    for g in grants {
864        let grantee_xml = if g.grantee_type == "Group" {
865            let uri = g.grantee_uri.as_deref().unwrap_or("");
866            format!(
867                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
868                 <URI>{}</URI></Grantee>",
869                xml_escape(uri),
870            )
871        } else {
872            let id = g.grantee_id.as_deref().unwrap_or("");
873            format!(
874                "<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
875                 <ID>{}</ID></Grantee>",
876                xml_escape(id),
877            )
878        };
879        grants_xml.push_str(&format!(
880            "<Grant>{grantee_xml}<Permission>{}</Permission></Grant>",
881            xml_escape(&g.permission),
882        ));
883    }
884
885    format!(
886        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
887         <AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
888         <Owner><ID>{owner_id}</ID><DisplayName>{owner_id}</DisplayName></Owner>\
889         <AccessControlList>{grants_xml}</AccessControlList>\
890         </AccessControlPolicy>",
891        owner_id = xml_escape(owner_id),
892    )
893}
894
895pub(crate) fn canned_acl_grants(acl: &str, owner_id: &str) -> Vec<AclGrant> {
896    let owner_grant = AclGrant {
897        grantee_type: "CanonicalUser".to_string(),
898        grantee_id: Some(owner_id.to_string()),
899        grantee_display_name: Some(owner_id.to_string()),
900        grantee_uri: None,
901        permission: "FULL_CONTROL".to_string(),
902    };
903    match acl {
904        "private" => vec![owner_grant],
905        "public-read" => vec![
906            owner_grant,
907            AclGrant {
908                grantee_type: "Group".to_string(),
909                grantee_id: None,
910                grantee_display_name: None,
911                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
912                permission: "READ".to_string(),
913            },
914        ],
915        "public-read-write" => vec![
916            owner_grant,
917            AclGrant {
918                grantee_type: "Group".to_string(),
919                grantee_id: None,
920                grantee_display_name: None,
921                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
922                permission: "READ".to_string(),
923            },
924            AclGrant {
925                grantee_type: "Group".to_string(),
926                grantee_id: None,
927                grantee_display_name: None,
928                grantee_uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".to_string()),
929                permission: "WRITE".to_string(),
930            },
931        ],
932        "authenticated-read" => vec![
933            owner_grant,
934            AclGrant {
935                grantee_type: "Group".to_string(),
936                grantee_id: None,
937                grantee_display_name: None,
938                grantee_uri: Some(
939                    "http://acs.amazonaws.com/groups/global/AuthenticatedUsers".to_string(),
940                ),
941                permission: "READ".to_string(),
942            },
943        ],
944        "bucket-owner-full-control" => vec![owner_grant],
945        _ => vec![owner_grant],
946    }
947}
948
949pub(crate) fn canned_acl_grants_for_object(acl: &str, owner_id: &str) -> Vec<AclGrant> {
950    // For objects, canned ACLs work the same way
951    canned_acl_grants(acl, owner_id)
952}
953
954pub(crate) fn parse_grant_headers(headers: &HeaderMap) -> Vec<AclGrant> {
955    let mut grants = Vec::new();
956    let header_permission_map = [
957        ("x-amz-grant-read", "READ"),
958        ("x-amz-grant-write", "WRITE"),
959        ("x-amz-grant-read-acp", "READ_ACP"),
960        ("x-amz-grant-write-acp", "WRITE_ACP"),
961        ("x-amz-grant-full-control", "FULL_CONTROL"),
962    ];
963
964    for (header, permission) in &header_permission_map {
965        if let Some(value) = headers.get(*header).and_then(|v| v.to_str().ok()) {
966            // Parse "id=xxx" or "uri=xxx" or "emailAddress=xxx"
967            for part in value.split(',') {
968                let part = part.trim();
969                if let Some((key, val)) = part.split_once('=') {
970                    let val = val.trim().trim_matches('"');
971                    let key = key.trim().to_lowercase();
972                    match key.as_str() {
973                        "id" => {
974                            grants.push(AclGrant {
975                                grantee_type: "CanonicalUser".to_string(),
976                                grantee_id: Some(val.to_string()),
977                                grantee_display_name: Some(val.to_string()),
978                                grantee_uri: None,
979                                permission: permission.to_string(),
980                            });
981                        }
982                        "uri" | "url" => {
983                            grants.push(AclGrant {
984                                grantee_type: "Group".to_string(),
985                                grantee_id: None,
986                                grantee_display_name: None,
987                                grantee_uri: Some(val.to_string()),
988                                permission: permission.to_string(),
989                            });
990                        }
991                        _ => {}
992                    }
993                }
994            }
995        }
996    }
997    grants
998}
999
1000pub(crate) fn parse_acl_xml(xml: &str) -> Result<Vec<AclGrant>, AwsServiceError> {
1001    // Check for Owner presence
1002    if xml.contains("<AccessControlPolicy") && !xml.contains("<Owner>") {
1003        return Err(AwsServiceError::aws_error(
1004            StatusCode::BAD_REQUEST,
1005            "MalformedACLError",
1006            "The XML you provided was not well-formed or did not validate against our published schema",
1007        ));
1008    }
1009
1010    let valid_permissions = ["READ", "WRITE", "READ_ACP", "WRITE_ACP", "FULL_CONTROL"];
1011
1012    let mut grants = Vec::new();
1013    let mut remaining = xml;
1014    while let Some(start) = remaining.find("<Grant>") {
1015        let after = &remaining[start + 7..];
1016        if let Some(end) = after.find("</Grant>") {
1017            let grant_body = &after[..end];
1018
1019            // Extract permission
1020            let permission = extract_xml_value(grant_body, "Permission").unwrap_or_default();
1021            if !valid_permissions.contains(&permission.as_str()) {
1022                return Err(AwsServiceError::aws_error(
1023                    StatusCode::BAD_REQUEST,
1024                    "MalformedACLError",
1025                    "The XML you provided was not well-formed or did not validate against our published schema",
1026                ));
1027            }
1028
1029            // Determine grantee type
1030            if grant_body.contains("xsi:type=\"Group\"") || grant_body.contains("<URI>") {
1031                let uri = extract_xml_value(grant_body, "URI").unwrap_or_default();
1032                grants.push(AclGrant {
1033                    grantee_type: "Group".to_string(),
1034                    grantee_id: None,
1035                    grantee_display_name: None,
1036                    grantee_uri: Some(uri),
1037                    permission,
1038                });
1039            } else {
1040                let id = extract_xml_value(grant_body, "ID").unwrap_or_default();
1041                let display =
1042                    extract_xml_value(grant_body, "DisplayName").unwrap_or_else(|| id.clone());
1043                grants.push(AclGrant {
1044                    grantee_type: "CanonicalUser".to_string(),
1045                    grantee_id: Some(id),
1046                    grantee_display_name: Some(display),
1047                    grantee_uri: None,
1048                    permission,
1049                });
1050            }
1051
1052            remaining = &after[end + 8..];
1053        } else {
1054            break;
1055        }
1056    }
1057    Ok(grants)
1058}
1059
1060// Range helpers
1061
1062pub(crate) enum RangeResult {
1063    Satisfiable { start: usize, end: usize },
1064    NotSatisfiable,
1065    Ignored,
1066}
1067
1068pub(crate) fn parse_range_header(range_str: &str, total_size: usize) -> Option<RangeResult> {
1069    let range_str = range_str.strip_prefix("bytes=")?;
1070    let (start_str, end_str) = range_str.split_once('-')?;
1071    if start_str.is_empty() {
1072        let suffix_len: usize = end_str.parse().ok()?;
1073        if suffix_len == 0 || total_size == 0 {
1074            return Some(RangeResult::NotSatisfiable);
1075        }
1076        let start = total_size.saturating_sub(suffix_len);
1077        Some(RangeResult::Satisfiable {
1078            start,
1079            end: total_size - 1,
1080        })
1081    } else {
1082        let start: usize = start_str.parse().ok()?;
1083        if start >= total_size {
1084            return Some(RangeResult::NotSatisfiable);
1085        }
1086        let end = if end_str.is_empty() {
1087            total_size - 1
1088        } else {
1089            let e: usize = end_str.parse().ok()?;
1090            if e < start {
1091                return Some(RangeResult::Ignored);
1092            }
1093            std::cmp::min(e, total_size - 1)
1094        };
1095        Some(RangeResult::Satisfiable { start, end })
1096    }
1097}
1098
1099// Helpers
1100
1101/// S3 XML response with `application/xml` content type (unlike Query protocol's `text/xml`).
1102pub(crate) fn s3_xml(status: StatusCode, body: impl Into<Bytes>) -> AwsResponse {
1103    AwsResponse {
1104        status,
1105        content_type: "application/xml".to_string(),
1106        body: body.into().into(),
1107        headers: HeaderMap::new(),
1108    }
1109}
1110
1111pub(crate) fn empty_response(status: StatusCode) -> AwsResponse {
1112    AwsResponse {
1113        status,
1114        content_type: "application/xml".to_string(),
1115        body: Bytes::new().into(),
1116        headers: HeaderMap::new(),
1117    }
1118}
1119
1120/// Returns true when the object is stored in a "cold" storage class (GLACIER, DEEP_ARCHIVE)
1121/// and has NOT been restored (or restore is still in progress).
1122pub(crate) fn is_frozen(obj: &S3Object) -> bool {
1123    matches!(obj.storage_class.as_str(), "GLACIER" | "DEEP_ARCHIVE")
1124        && obj.restore_ongoing != Some(false)
1125}
1126
1127pub(crate) fn no_such_bucket(bucket: &str) -> AwsServiceError {
1128    AwsServiceError::aws_error_with_fields(
1129        StatusCode::NOT_FOUND,
1130        "NoSuchBucket",
1131        "The specified bucket does not exist",
1132        vec![("BucketName".to_string(), bucket.to_string())],
1133    )
1134}
1135
1136pub(crate) fn no_such_key(key: &str) -> AwsServiceError {
1137    AwsServiceError::aws_error_with_fields(
1138        StatusCode::NOT_FOUND,
1139        "NoSuchKey",
1140        "The specified key does not exist.",
1141        vec![("Key".to_string(), key.to_string())],
1142    )
1143}
1144
1145pub(crate) fn no_such_upload(upload_id: &str) -> AwsServiceError {
1146    AwsServiceError::aws_error_with_fields(
1147        StatusCode::NOT_FOUND,
1148        "NoSuchUpload",
1149        "The specified upload does not exist. The upload ID may be invalid, \
1150         or the upload may have been aborted or completed.",
1151        vec![("UploadId".to_string(), upload_id.to_string())],
1152    )
1153}
1154
1155pub(crate) fn no_such_key_with_detail(key: &str) -> AwsServiceError {
1156    AwsServiceError::aws_error_with_fields(
1157        StatusCode::NOT_FOUND,
1158        "NoSuchKey",
1159        "The specified key does not exist.",
1160        vec![("Key".to_string(), key.to_string())],
1161    )
1162}
1163
1164pub(crate) fn compute_md5(data: &[u8]) -> String {
1165    let digest = Md5::digest(data);
1166    format!("{:x}", digest)
1167}
1168
1169pub(crate) fn compute_checksum(algorithm: &str, data: &[u8]) -> String {
1170    match algorithm {
1171        "CRC32" => {
1172            let crc = crc32fast::hash(data);
1173            BASE64.encode(crc.to_be_bytes())
1174        }
1175        "SHA1" => {
1176            use sha1::Digest as _;
1177            let hash = sha1::Sha1::digest(data);
1178            BASE64.encode(hash)
1179        }
1180        "SHA256" => {
1181            use sha2::Digest as _;
1182            let hash = sha2::Sha256::digest(data);
1183            BASE64.encode(hash)
1184        }
1185        _ => String::new(),
1186    }
1187}
1188
1189pub(crate) fn url_encode_s3_key(s: &str) -> String {
1190    let mut out = String::with_capacity(s.len() * 2);
1191    for byte in s.bytes() {
1192        match byte {
1193            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
1194                out.push(byte as char);
1195            }
1196            _ => {
1197                out.push_str(&format!("%{:02X}", byte));
1198            }
1199        }
1200    }
1201    out
1202}
1203
1204pub(crate) use fakecloud_aws::xml::xml_escape;
1205
1206pub(crate) fn extract_user_metadata(
1207    headers: &HeaderMap,
1208) -> std::collections::HashMap<String, String> {
1209    let mut meta = std::collections::HashMap::new();
1210    for (name, value) in headers {
1211        if let Some(key) = name.as_str().strip_prefix("x-amz-meta-") {
1212            if let Ok(v) = value.to_str() {
1213                meta.insert(key.to_string(), v.to_string());
1214            }
1215        }
1216    }
1217    meta
1218}
1219
1220pub(crate) fn is_valid_storage_class(class: &str) -> bool {
1221    matches!(
1222        class,
1223        "STANDARD"
1224            | "REDUCED_REDUNDANCY"
1225            | "STANDARD_IA"
1226            | "ONEZONE_IA"
1227            | "INTELLIGENT_TIERING"
1228            | "GLACIER"
1229            | "DEEP_ARCHIVE"
1230            | "GLACIER_IR"
1231            | "OUTPOSTS"
1232            | "SNOW"
1233            | "EXPRESS_ONEZONE"
1234    )
1235}
1236
1237pub(crate) fn is_valid_bucket_name(name: &str) -> bool {
1238    if name.len() < 3 || name.len() > 63 {
1239        return false;
1240    }
1241    // Must start and end with alphanumeric
1242    let bytes = name.as_bytes();
1243    if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
1244        return false;
1245    }
1246    // Only lowercase letters, digits, hyphens, dots (also allow underscores for compatibility)
1247    name.chars()
1248        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '_')
1249}
1250
1251pub(crate) fn is_valid_region(region: &str) -> bool {
1252    // Basic validation: region should match pattern like us-east-1, eu-west-2, etc.
1253    let valid_regions = [
1254        "us-east-1",
1255        "us-east-2",
1256        "us-west-1",
1257        "us-west-2",
1258        "af-south-1",
1259        "ap-east-1",
1260        "ap-south-1",
1261        "ap-south-2",
1262        "ap-southeast-1",
1263        "ap-southeast-2",
1264        "ap-southeast-3",
1265        "ap-southeast-4",
1266        "ap-northeast-1",
1267        "ap-northeast-2",
1268        "ap-northeast-3",
1269        "ca-central-1",
1270        "ca-west-1",
1271        "eu-central-1",
1272        "eu-central-2",
1273        "eu-west-1",
1274        "eu-west-2",
1275        "eu-west-3",
1276        "eu-south-1",
1277        "eu-south-2",
1278        "eu-north-1",
1279        "il-central-1",
1280        "me-south-1",
1281        "me-central-1",
1282        "sa-east-1",
1283        "cn-north-1",
1284        "cn-northwest-1",
1285        "us-gov-east-1",
1286        "us-gov-east-2",
1287        "us-gov-west-1",
1288        "us-iso-east-1",
1289        "us-iso-west-1",
1290        "us-isob-east-1",
1291        "us-isof-south-1",
1292    ];
1293    valid_regions.contains(&region)
1294}
1295
1296pub(crate) fn resolve_object<'a>(
1297    b: &'a S3Bucket,
1298    key: &str,
1299    version_id: Option<&String>,
1300) -> Result<&'a S3Object, AwsServiceError> {
1301    if let Some(vid) = version_id {
1302        // "null" version ID refers to an object with no version_id (pre-versioning)
1303        if vid == "null" {
1304            // Check versions for a pre-versioning object (version_id == None or Some("null"))
1305            if let Some(versions) = b.object_versions.get(key) {
1306                if let Some(obj) = versions
1307                    .iter()
1308                    .find(|o| o.version_id.is_none() || o.version_id.as_deref() == Some("null"))
1309                {
1310                    return Ok(obj);
1311                }
1312            }
1313            // Also check current object if it has no version_id
1314            if let Some(obj) = b.objects.get(key) {
1315                if obj.version_id.is_none() || obj.version_id.as_deref() == Some("null") {
1316                    return Ok(obj);
1317                }
1318            }
1319        } else {
1320            // When a specific versionId is requested, check versions first
1321            if let Some(versions) = b.object_versions.get(key) {
1322                if let Some(obj) = versions
1323                    .iter()
1324                    .find(|o| o.version_id.as_deref() == Some(vid.as_str()))
1325                {
1326                    return Ok(obj);
1327                }
1328            }
1329            // Also check current object
1330            if let Some(obj) = b.objects.get(key) {
1331                if obj.version_id.as_deref() == Some(vid.as_str()) {
1332                    return Ok(obj);
1333                }
1334            }
1335        }
1336        // For versioned buckets, return NoSuchVersion; for non-versioned, return 400
1337        if b.versioning.is_some() {
1338            Err(AwsServiceError::aws_error_with_fields(
1339                StatusCode::NOT_FOUND,
1340                "NoSuchVersion",
1341                "The specified version does not exist.",
1342                vec![
1343                    ("Key".to_string(), key.to_string()),
1344                    ("VersionId".to_string(), vid.to_string()),
1345                ],
1346            ))
1347        } else {
1348            Err(AwsServiceError::aws_error(
1349                StatusCode::BAD_REQUEST,
1350                "InvalidArgument",
1351                "Invalid version id specified",
1352            ))
1353        }
1354    } else {
1355        b.objects.get(key).ok_or_else(|| no_such_key(key))
1356    }
1357}
1358
1359pub(crate) fn make_delete_marker(key: &str, dm_id: &str) -> S3Object {
1360    S3Object {
1361        key: key.to_string(),
1362        last_modified: Utc::now(),
1363        storage_class: "STANDARD".to_string(),
1364        version_id: Some(dm_id.to_string()),
1365        is_delete_marker: true,
1366        ..Default::default()
1367    }
1368}
1369
1370/// Represents an object to delete in a batch delete request.
1371pub(crate) struct DeleteObjectEntry {
1372    key: String,
1373    version_id: Option<String>,
1374}
1375
1376pub(crate) fn parse_delete_objects_xml(xml: &str) -> Vec<DeleteObjectEntry> {
1377    let mut entries = Vec::new();
1378    let mut remaining = xml;
1379    while let Some(obj_start) = remaining.find("<Object>") {
1380        let after = &remaining[obj_start + 8..];
1381        if let Some(obj_end) = after.find("</Object>") {
1382            let obj_body = &after[..obj_end];
1383            let key = extract_xml_value(obj_body, "Key");
1384            let version_id = extract_xml_value(obj_body, "VersionId");
1385            if let Some(k) = key {
1386                entries.push(DeleteObjectEntry { key: k, version_id });
1387            }
1388            remaining = &after[obj_end + 9..];
1389        } else {
1390            break;
1391        }
1392    }
1393    entries
1394}
1395
1396/// Minimal XML parser for `<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag>...`.
1397/// Returns a Vec to preserve insertion order and detect duplicates.
1398pub(crate) fn parse_tagging_xml(xml: &str) -> Vec<(String, String)> {
1399    let mut tags = Vec::new();
1400    let mut remaining = xml;
1401    while let Some(tag_start) = remaining.find("<Tag>") {
1402        let after = &remaining[tag_start + 5..];
1403        if let Some(tag_end) = after.find("</Tag>") {
1404            let tag_body = &after[..tag_end];
1405            let key = extract_xml_value(tag_body, "Key");
1406            let value = extract_xml_value(tag_body, "Value");
1407            if let (Some(k), Some(v)) = (key, value) {
1408                tags.push((k, v));
1409            }
1410            remaining = &after[tag_end + 6..];
1411        } else {
1412            break;
1413        }
1414    }
1415    tags
1416}
1417
1418pub(crate) fn validate_tags(tags: &[(String, String)]) -> Result<(), AwsServiceError> {
1419    // Check for duplicate keys
1420    let mut seen = std::collections::HashSet::new();
1421    for (k, _) in tags {
1422        if !seen.insert(k.as_str()) {
1423            return Err(AwsServiceError::aws_error(
1424                StatusCode::BAD_REQUEST,
1425                "InvalidTag",
1426                "Cannot provide multiple Tags with the same key",
1427            ));
1428        }
1429        // Check for aws: prefix
1430        if k.starts_with("aws:") {
1431            return Err(AwsServiceError::aws_error(
1432                StatusCode::BAD_REQUEST,
1433                "InvalidTag",
1434                "System tags cannot be added/updated by requester",
1435            ));
1436        }
1437    }
1438    Ok(())
1439}
1440
1441pub(crate) fn extract_xml_value(xml: &str, tag: &str) -> Option<String> {
1442    // Handle self-closing tags like <Value /> or <Value/>
1443    let self_closing1 = format!("<{tag} />");
1444    let self_closing2 = format!("<{tag}/>");
1445    if xml.contains(&self_closing1) || xml.contains(&self_closing2) {
1446        // Check if the self-closing tag appears before any open+close pair
1447        let self_pos = xml
1448            .find(&self_closing1)
1449            .or_else(|| xml.find(&self_closing2));
1450        let open = format!("<{tag}>");
1451        let open_pos = xml.find(&open);
1452        match (self_pos, open_pos) {
1453            (Some(sp), Some(op)) if sp < op => return Some(String::new()),
1454            (Some(_), None) => return Some(String::new()),
1455            _ => {}
1456        }
1457    }
1458
1459    let open = format!("<{tag}>");
1460    let close = format!("</{tag}>");
1461    let start = xml.find(&open)? + open.len();
1462    let end = xml.find(&close)?;
1463    Some(xml[start..end].to_string())
1464}
1465
1466/// Parse the CompleteMultipartUpload XML body into (part_number, etag) pairs.
1467pub(crate) fn parse_complete_multipart_xml(xml: &str) -> Vec<(u32, String)> {
1468    let mut parts = Vec::new();
1469    let mut remaining = xml;
1470    while let Some(part_start) = remaining.find("<Part>") {
1471        let after = &remaining[part_start + 6..];
1472        if let Some(part_end) = after.find("</Part>") {
1473            let part_body = &after[..part_end];
1474            let part_num =
1475                extract_xml_value(part_body, "PartNumber").and_then(|s| s.parse::<u32>().ok());
1476            let etag = extract_xml_value(part_body, "ETag")
1477                .map(|s| s.replace("&quot;", "").replace('"', ""));
1478            if let (Some(num), Some(e)) = (part_num, etag) {
1479                parts.push((num, e));
1480            }
1481            remaining = &after[part_end + 7..];
1482        } else {
1483            break;
1484        }
1485    }
1486    parts
1487}
1488
1489pub(crate) fn parse_url_encoded_tags(s: &str) -> Vec<(String, String)> {
1490    let mut tags = Vec::new();
1491    for pair in s.split('&') {
1492        if pair.is_empty() {
1493            continue;
1494        }
1495        let (key, value) = match pair.find('=') {
1496            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1497            None => (pair, ""),
1498        };
1499        tags.push((
1500            percent_encoding::percent_decode_str(key)
1501                .decode_utf8_lossy()
1502                .to_string(),
1503            percent_encoding::percent_decode_str(value)
1504                .decode_utf8_lossy()
1505                .to_string(),
1506        ));
1507    }
1508    tags
1509}
1510
1511/// Validate lifecycle configuration XML. Returns MalformedXML on invalid configs.
1512pub(crate) fn validate_lifecycle_xml(xml: &str) -> Result<(), AwsServiceError> {
1513    let malformed = || {
1514        AwsServiceError::aws_error(
1515            StatusCode::BAD_REQUEST,
1516            "MalformedXML",
1517            "The XML you provided was not well-formed or did not validate against our published schema",
1518        )
1519    };
1520
1521    let mut remaining = xml;
1522    while let Some(rule_start) = remaining.find("<Rule>") {
1523        let after = &remaining[rule_start + 6..];
1524        if let Some(rule_end) = after.find("</Rule>") {
1525            let rule_body = &after[..rule_end];
1526
1527            // Must have Filter or Prefix
1528            let has_filter = rule_body.contains("<Filter>")
1529                || rule_body.contains("<Filter/>")
1530                || rule_body.contains("<Filter />");
1531
1532            // Check for <Prefix> at rule level (outside of <Filter>...</Filter>)
1533            let has_prefix_outside_filter = {
1534                if !rule_body.contains("<Prefix") {
1535                    false
1536                } else if !has_filter {
1537                    true // No filter means any Prefix is at rule level
1538                } else {
1539                    // Remove the Filter block and check if Prefix remains
1540                    let mut stripped = rule_body.to_string();
1541                    // Remove <Filter>...</Filter> or self-closing variants
1542                    if let Some(fs) = stripped.find("<Filter") {
1543                        if let Some(fe) = stripped.find("</Filter>") {
1544                            stripped = format!("{}{}", &stripped[..fs], &stripped[fe + 9..]);
1545                        }
1546                    }
1547                    stripped.contains("<Prefix")
1548                }
1549            };
1550
1551            if !has_filter && !has_prefix_outside_filter {
1552                return Err(malformed());
1553            }
1554            // Can't have both Filter and rule-level Prefix
1555            if has_filter && has_prefix_outside_filter {
1556                return Err(malformed());
1557            }
1558
1559            // Expiration: if has ExpiredObjectDeleteMarker, cannot also have Days or Date
1560            // (only check within <Expiration> block)
1561            if let Some(exp_start) = rule_body.find("<Expiration>") {
1562                if let Some(exp_end) = rule_body[exp_start..].find("</Expiration>") {
1563                    let exp_body = &rule_body[exp_start..exp_start + exp_end];
1564                    if exp_body.contains("<ExpiredObjectDeleteMarker>")
1565                        && (exp_body.contains("<Days>") || exp_body.contains("<Date>"))
1566                    {
1567                        return Err(malformed());
1568                    }
1569                }
1570            }
1571
1572            // Filter validation
1573            if has_filter {
1574                if let Some(fs) = rule_body.find("<Filter>") {
1575                    if let Some(fe) = rule_body.find("</Filter>") {
1576                        let filter_body = &rule_body[fs + 8..fe];
1577                        let has_prefix_in_filter = filter_body.contains("<Prefix");
1578                        let has_tag_in_filter = filter_body.contains("<Tag>");
1579                        let has_and_in_filter = filter_body.contains("<And>");
1580                        // Can't have both Prefix and Tag without And
1581                        if has_prefix_in_filter && has_tag_in_filter && !has_and_in_filter {
1582                            return Err(malformed());
1583                        }
1584                        // Can't have Tag and And simultaneously at the Filter level
1585                        if has_tag_in_filter && has_and_in_filter {
1586                            // Check if the <Tag> is outside <And>
1587                            let and_start = filter_body.find("<And>").unwrap_or(0);
1588                            let tag_pos = filter_body.find("<Tag>").unwrap_or(0);
1589                            if tag_pos < and_start {
1590                                return Err(malformed());
1591                            }
1592                        }
1593                    }
1594                }
1595            }
1596
1597            // NoncurrentVersionTransition must have NoncurrentDays and StorageClass
1598            if rule_body.contains("<NoncurrentVersionTransition>") {
1599                let mut nvt_remaining = rule_body;
1600                while let Some(nvt_start) = nvt_remaining.find("<NoncurrentVersionTransition>") {
1601                    let nvt_after = &nvt_remaining[nvt_start + 29..];
1602                    if let Some(nvt_end) = nvt_after.find("</NoncurrentVersionTransition>") {
1603                        let nvt_body = &nvt_after[..nvt_end];
1604                        if !nvt_body.contains("<NoncurrentDays>") {
1605                            return Err(malformed());
1606                        }
1607                        if !nvt_body.contains("<StorageClass>") {
1608                            return Err(malformed());
1609                        }
1610                        nvt_remaining = &nvt_after[nvt_end + 30..];
1611                    } else {
1612                        break;
1613                    }
1614                }
1615            }
1616
1617            remaining = &after[rule_end + 7..];
1618        } else {
1619            break;
1620        }
1621    }
1622
1623    Ok(())
1624}
1625
1626/// Parsed CORS rule from bucket configuration XML.
1627pub(crate) struct CorsRule {
1628    allowed_origins: Vec<String>,
1629    allowed_methods: Vec<String>,
1630    allowed_headers: Vec<String>,
1631    expose_headers: Vec<String>,
1632    max_age_seconds: Option<u32>,
1633}
1634
1635/// Parse CORS configuration XML into rules.
1636pub(crate) fn parse_cors_config(xml: &str) -> Vec<CorsRule> {
1637    let mut rules = Vec::new();
1638    let mut remaining = xml;
1639    while let Some(start) = remaining.find("<CORSRule>") {
1640        let after = &remaining[start + 10..];
1641        if let Some(end) = after.find("</CORSRule>") {
1642            let block = &after[..end];
1643            let allowed_origins = extract_all_xml_values(block, "AllowedOrigin");
1644            let allowed_methods = extract_all_xml_values(block, "AllowedMethod");
1645            let allowed_headers = extract_all_xml_values(block, "AllowedHeader");
1646            let expose_headers = extract_all_xml_values(block, "ExposeHeader");
1647            let max_age_seconds =
1648                extract_xml_value(block, "MaxAgeSeconds").and_then(|s| s.parse().ok());
1649            rules.push(CorsRule {
1650                allowed_origins,
1651                allowed_methods,
1652                allowed_headers,
1653                expose_headers,
1654                max_age_seconds,
1655            });
1656            remaining = &after[end + 11..];
1657        } else {
1658            break;
1659        }
1660    }
1661    rules
1662}
1663
1664/// Match an origin against a CORS allowed origin pattern (supports "*" wildcard).
1665pub(crate) fn origin_matches(origin: &str, pattern: &str) -> bool {
1666    if pattern == "*" {
1667        return true;
1668    }
1669    // Simple wildcard: *.example.com
1670    if let Some(suffix) = pattern.strip_prefix('*') {
1671        return origin.ends_with(suffix);
1672    }
1673    origin == pattern
1674}
1675
1676/// Find the matching CORS rule for a given origin and method.
1677pub(crate) fn find_cors_rule<'a>(
1678    rules: &'a [CorsRule],
1679    origin: &str,
1680    method: Option<&str>,
1681) -> Option<&'a CorsRule> {
1682    rules.iter().find(|rule| {
1683        let origin_ok = rule
1684            .allowed_origins
1685            .iter()
1686            .any(|o| origin_matches(origin, o));
1687        let method_ok = match method {
1688            Some(m) => rule.allowed_methods.iter().any(|am| am == m),
1689            None => true,
1690        };
1691        origin_ok && method_ok
1692    })
1693}
1694
1695/// Check if an object is locked (retention or legal hold) and should block mutation.
1696/// Returns an error string if locked, None if allowed.
1697pub(crate) fn check_object_lock_for_overwrite(
1698    obj: &S3Object,
1699    req: &AwsRequest,
1700) -> Option<&'static str> {
1701    // Legal hold blocks overwrite
1702    if obj.lock_legal_hold.as_deref() == Some("ON") {
1703        return Some("AccessDenied");
1704    }
1705    // Retention check
1706    if let (Some(mode), Some(until)) = (&obj.lock_mode, &obj.lock_retain_until) {
1707        if *until > Utc::now() {
1708            if mode == "COMPLIANCE" {
1709                return Some("AccessDenied");
1710            }
1711            if mode == "GOVERNANCE" {
1712                let bypass = req
1713                    .headers
1714                    .get("x-amz-bypass-governance-retention")
1715                    .and_then(|v| v.to_str().ok())
1716                    .map(|s| s.eq_ignore_ascii_case("true"))
1717                    .unwrap_or(false);
1718                if !bypass {
1719                    return Some("AccessDenied");
1720                }
1721            }
1722        }
1723    }
1724    None
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    #[test]
1732    fn valid_bucket_names() {
1733        assert!(is_valid_bucket_name("my-bucket"));
1734        assert!(is_valid_bucket_name("my.bucket.name"));
1735        assert!(is_valid_bucket_name("abc"));
1736        assert!(!is_valid_bucket_name("ab"));
1737        assert!(!is_valid_bucket_name("-bucket"));
1738        assert!(!is_valid_bucket_name("Bucket"));
1739        assert!(!is_valid_bucket_name("bucket-"));
1740    }
1741
1742    #[test]
1743    fn parse_delete_xml() {
1744        let xml = r#"<Delete><Object><Key>a.txt</Key></Object><Object><Key>b/c.txt</Key></Object></Delete>"#;
1745        let entries = parse_delete_objects_xml(xml);
1746        assert_eq!(entries.len(), 2);
1747        assert_eq!(entries[0].key, "a.txt");
1748        assert!(entries[0].version_id.is_none());
1749        assert_eq!(entries[1].key, "b/c.txt");
1750    }
1751
1752    #[test]
1753    fn parse_delete_xml_with_version() {
1754        let xml = r#"<Delete><Object><Key>a.txt</Key><VersionId>v1</VersionId></Object></Delete>"#;
1755        let entries = parse_delete_objects_xml(xml);
1756        assert_eq!(entries.len(), 1);
1757        assert_eq!(entries[0].key, "a.txt");
1758        assert_eq!(entries[0].version_id.as_deref(), Some("v1"));
1759    }
1760
1761    #[test]
1762    fn parse_tags_xml() {
1763        let xml =
1764            r#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#;
1765        let tags = parse_tagging_xml(xml);
1766        assert_eq!(tags, vec![("env".to_string(), "prod".to_string())]);
1767    }
1768
1769    #[test]
1770    fn md5_hash() {
1771        let hash = compute_md5(b"hello");
1772        assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
1773    }
1774
1775    #[test]
1776    fn test_etag_matches() {
1777        assert!(etag_matches("\"abc\"", "\"abc\""));
1778        assert!(etag_matches("abc", "\"abc\""));
1779        assert!(etag_matches("*", "\"abc\""));
1780        assert!(!etag_matches("\"xyz\"", "\"abc\""));
1781    }
1782
1783    #[test]
1784    fn test_event_matches() {
1785        assert!(event_matches("s3:ObjectCreated:Put", "s3:ObjectCreated:*"));
1786        assert!(event_matches("s3:ObjectCreated:Copy", "s3:ObjectCreated:*"));
1787        assert!(event_matches(
1788            "s3:ObjectRemoved:Delete",
1789            "s3:ObjectRemoved:*"
1790        ));
1791        assert!(!event_matches(
1792            "s3:ObjectRemoved:Delete",
1793            "s3:ObjectCreated:*"
1794        ));
1795        assert!(event_matches(
1796            "s3:ObjectCreated:Put",
1797            "s3:ObjectCreated:Put"
1798        ));
1799        assert!(event_matches("s3:ObjectCreated:Put", "s3:*"));
1800    }
1801
1802    #[test]
1803    fn test_parse_notification_config() {
1804        let xml = r#"<NotificationConfiguration>
1805            <QueueConfiguration>
1806                <Queue>arn:aws:sqs:us-east-1:123456789012:my-queue</Queue>
1807                <Event>s3:ObjectCreated:*</Event>
1808            </QueueConfiguration>
1809            <TopicConfiguration>
1810                <Topic>arn:aws:sns:us-east-1:123456789012:my-topic</Topic>
1811                <Event>s3:ObjectRemoved:*</Event>
1812            </TopicConfiguration>
1813        </NotificationConfiguration>"#;
1814        let targets = parse_notification_config(xml);
1815        assert_eq!(targets.len(), 2);
1816        assert_eq!(
1817            targets[0].arn,
1818            "arn:aws:sqs:us-east-1:123456789012:my-queue"
1819        );
1820        assert_eq!(targets[0].events, vec!["s3:ObjectCreated:*"]);
1821        assert_eq!(
1822            targets[1].arn,
1823            "arn:aws:sns:us-east-1:123456789012:my-topic"
1824        );
1825        assert_eq!(targets[1].events, vec!["s3:ObjectRemoved:*"]);
1826    }
1827
1828    #[test]
1829    fn test_parse_notification_config_lambda() {
1830        // Test CloudFunctionConfiguration (older format)
1831        let xml = r#"<NotificationConfiguration>
1832            <CloudFunctionConfiguration>
1833                <CloudFunction>arn:aws:lambda:us-east-1:123456789012:function:my-func</CloudFunction>
1834                <Event>s3:ObjectCreated:*</Event>
1835            </CloudFunctionConfiguration>
1836        </NotificationConfiguration>"#;
1837        let targets = parse_notification_config(xml);
1838        assert_eq!(targets.len(), 1);
1839        assert!(matches!(
1840            targets[0].target_type,
1841            NotificationTargetType::Lambda
1842        ));
1843        assert_eq!(
1844            targets[0].arn,
1845            "arn:aws:lambda:us-east-1:123456789012:function:my-func"
1846        );
1847        assert_eq!(targets[0].events, vec!["s3:ObjectCreated:*"]);
1848    }
1849
1850    #[test]
1851    fn test_parse_notification_config_lambda_new_format() {
1852        // Test LambdaFunctionConfiguration (newer format used by AWS SDK)
1853        let xml = r#"<NotificationConfiguration>
1854            <LambdaFunctionConfiguration>
1855                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
1856                <Event>s3:ObjectCreated:Put</Event>
1857                <Event>s3:ObjectRemoved:*</Event>
1858            </LambdaFunctionConfiguration>
1859        </NotificationConfiguration>"#;
1860        let targets = parse_notification_config(xml);
1861        assert_eq!(targets.len(), 1);
1862        assert!(matches!(
1863            targets[0].target_type,
1864            NotificationTargetType::Lambda
1865        ));
1866        assert_eq!(
1867            targets[0].arn,
1868            "arn:aws:lambda:us-east-1:123456789012:function:my-func"
1869        );
1870        assert_eq!(
1871            targets[0].events,
1872            vec!["s3:ObjectCreated:Put", "s3:ObjectRemoved:*"]
1873        );
1874    }
1875
1876    #[test]
1877    fn test_parse_notification_config_all_types() {
1878        let xml = r#"<NotificationConfiguration>
1879            <QueueConfiguration>
1880                <Queue>arn:aws:sqs:us-east-1:123456789012:q</Queue>
1881                <Event>s3:ObjectCreated:*</Event>
1882            </QueueConfiguration>
1883            <TopicConfiguration>
1884                <Topic>arn:aws:sns:us-east-1:123456789012:t</Topic>
1885                <Event>s3:ObjectRemoved:*</Event>
1886            </TopicConfiguration>
1887            <LambdaFunctionConfiguration>
1888                <Function>arn:aws:lambda:us-east-1:123456789012:function:f</Function>
1889                <Event>s3:ObjectCreated:Put</Event>
1890            </LambdaFunctionConfiguration>
1891        </NotificationConfiguration>"#;
1892        let targets = parse_notification_config(xml);
1893        assert_eq!(targets.len(), 3);
1894        assert!(matches!(
1895            targets[0].target_type,
1896            NotificationTargetType::Sqs
1897        ));
1898        assert!(matches!(
1899            targets[1].target_type,
1900            NotificationTargetType::Sns
1901        ));
1902        assert!(matches!(
1903            targets[2].target_type,
1904            NotificationTargetType::Lambda
1905        ));
1906    }
1907
1908    #[test]
1909    fn test_parse_notification_config_with_filters() {
1910        let xml = r#"<NotificationConfiguration>
1911            <LambdaFunctionConfiguration>
1912                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
1913                <Event>s3:ObjectCreated:*</Event>
1914                <Filter>
1915                    <S3Key>
1916                        <FilterRule>
1917                            <Name>prefix</Name>
1918                            <Value>images/</Value>
1919                        </FilterRule>
1920                        <FilterRule>
1921                            <Name>suffix</Name>
1922                            <Value>.jpg</Value>
1923                        </FilterRule>
1924                    </S3Key>
1925                </Filter>
1926            </LambdaFunctionConfiguration>
1927        </NotificationConfiguration>"#;
1928        let targets = parse_notification_config(xml);
1929        assert_eq!(targets.len(), 1);
1930        assert_eq!(targets[0].prefix_filter, Some("images/".to_string()));
1931        assert_eq!(targets[0].suffix_filter, Some(".jpg".to_string()));
1932    }
1933
1934    #[test]
1935    fn test_parse_notification_config_no_filters() {
1936        let xml = r#"<NotificationConfiguration>
1937            <LambdaFunctionConfiguration>
1938                <Function>arn:aws:lambda:us-east-1:123456789012:function:my-func</Function>
1939                <Event>s3:ObjectCreated:*</Event>
1940            </LambdaFunctionConfiguration>
1941        </NotificationConfiguration>"#;
1942        let targets = parse_notification_config(xml);
1943        assert_eq!(targets.len(), 1);
1944        assert_eq!(targets[0].prefix_filter, None);
1945        assert_eq!(targets[0].suffix_filter, None);
1946    }
1947
1948    #[test]
1949    fn test_key_matches_filters() {
1950        // No filters — everything matches
1951        assert!(key_matches_filters("anything", &None, &None));
1952
1953        // Prefix only
1954        assert!(key_matches_filters(
1955            "images/photo.jpg",
1956            &Some("images/".to_string()),
1957            &None
1958        ));
1959        assert!(!key_matches_filters(
1960            "docs/file.txt",
1961            &Some("images/".to_string()),
1962            &None
1963        ));
1964
1965        // Suffix only
1966        assert!(key_matches_filters(
1967            "images/photo.jpg",
1968            &None,
1969            &Some(".jpg".to_string())
1970        ));
1971        assert!(!key_matches_filters(
1972            "images/photo.png",
1973            &None,
1974            &Some(".jpg".to_string())
1975        ));
1976
1977        // Both prefix and suffix
1978        assert!(key_matches_filters(
1979            "images/photo.jpg",
1980            &Some("images/".to_string()),
1981            &Some(".jpg".to_string())
1982        ));
1983        assert!(!key_matches_filters(
1984            "images/photo.png",
1985            &Some("images/".to_string()),
1986            &Some(".jpg".to_string())
1987        ));
1988        assert!(!key_matches_filters(
1989            "docs/photo.jpg",
1990            &Some("images/".to_string()),
1991            &Some(".jpg".to_string())
1992        ));
1993    }
1994
1995    #[test]
1996    fn test_parse_cors_config() {
1997        let xml = r#"<CORSConfiguration>
1998            <CORSRule>
1999                <AllowedOrigin>https://example.com</AllowedOrigin>
2000                <AllowedMethod>GET</AllowedMethod>
2001                <AllowedMethod>PUT</AllowedMethod>
2002                <AllowedHeader>*</AllowedHeader>
2003                <ExposeHeader>x-amz-request-id</ExposeHeader>
2004                <MaxAgeSeconds>3600</MaxAgeSeconds>
2005            </CORSRule>
2006        </CORSConfiguration>"#;
2007        let rules = parse_cors_config(xml);
2008        assert_eq!(rules.len(), 1);
2009        assert_eq!(rules[0].allowed_origins, vec!["https://example.com"]);
2010        assert_eq!(rules[0].allowed_methods, vec!["GET", "PUT"]);
2011        assert_eq!(rules[0].allowed_headers, vec!["*"]);
2012        assert_eq!(rules[0].expose_headers, vec!["x-amz-request-id"]);
2013        assert_eq!(rules[0].max_age_seconds, Some(3600));
2014    }
2015
2016    #[test]
2017    fn test_origin_matches() {
2018        assert!(origin_matches("https://example.com", "https://example.com"));
2019        assert!(origin_matches("https://example.com", "*"));
2020        assert!(origin_matches("https://foo.example.com", "*.example.com"));
2021        assert!(!origin_matches("https://evil.com", "https://example.com"));
2022    }
2023
2024    /// Regression: resolve_object with versionId="null" must match objects
2025    /// whose version_id is either None or Some("null").
2026    #[test]
2027    fn resolve_null_version_matches_both_none_and_null_string() {
2028        use crate::state::S3Bucket;
2029        use bytes::Bytes;
2030        use chrono::Utc;
2031
2032        let mut b = S3Bucket::new("test", "us-east-1", "owner");
2033
2034        // Helper to create a minimal S3Object
2035        let make_obj = |key: &str, vid: Option<&str>| crate::state::S3Object {
2036            key: key.to_string(),
2037            body: crate::state::memory_body(Bytes::from_static(b"x")),
2038            content_type: "text/plain".to_string(),
2039            etag: "\"abc\"".to_string(),
2040            size: 1,
2041            last_modified: Utc::now(),
2042            storage_class: "STANDARD".to_string(),
2043            version_id: vid.map(|s| s.to_string()),
2044            ..Default::default()
2045        };
2046
2047        // Object with version_id = Some("null") (pre-versioning migrated)
2048        let obj = make_obj("file.txt", Some("null"));
2049        b.objects.insert("file.txt".to_string(), obj.clone());
2050        b.object_versions.insert("file.txt".to_string(), vec![obj]);
2051
2052        let null_str = "null".to_string();
2053        let result = resolve_object(&b, "file.txt", Some(&null_str));
2054        assert!(
2055            result.is_ok(),
2056            "versionId=null should match version_id=Some(\"null\")"
2057        );
2058
2059        // Object with version_id = None (true pre-versioning)
2060        let obj2 = make_obj("file2.txt", None);
2061        b.objects.insert("file2.txt".to_string(), obj2.clone());
2062        b.object_versions
2063            .insert("file2.txt".to_string(), vec![obj2]);
2064
2065        let result2 = resolve_object(&b, "file2.txt", Some(&null_str));
2066        assert!(
2067            result2.is_ok(),
2068            "versionId=null should match version_id=None"
2069        );
2070    }
2071
2072    #[test]
2073    fn test_parse_replication_rules() {
2074        let xml = r#"<ReplicationConfiguration>
2075            <Role>arn:aws:iam::role/replication</Role>
2076            <Rule>
2077                <Status>Enabled</Status>
2078                <Filter><Prefix>logs/</Prefix></Filter>
2079                <Destination><Bucket>arn:aws:s3:::dest-bucket</Bucket></Destination>
2080            </Rule>
2081            <Rule>
2082                <Status>Disabled</Status>
2083                <Filter><Prefix></Prefix></Filter>
2084                <Destination><Bucket>arn:aws:s3:::other-bucket</Bucket></Destination>
2085            </Rule>
2086        </ReplicationConfiguration>"#;
2087
2088        let rules = parse_replication_rules(xml);
2089        assert_eq!(rules.len(), 2);
2090        assert_eq!(rules[0].status, "Enabled");
2091        assert_eq!(rules[0].prefix, "logs/");
2092        assert_eq!(rules[0].dest_bucket, "dest-bucket");
2093        assert_eq!(rules[1].status, "Disabled");
2094        assert_eq!(rules[1].prefix, "");
2095        assert_eq!(rules[1].dest_bucket, "other-bucket");
2096    }
2097
2098    #[test]
2099    fn test_parse_normalized_replication_rules() {
2100        // First, normalize the XML like the server does
2101        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>"#;
2102        let normalized = normalize_replication_xml(input_xml);
2103        eprintln!("Normalized XML: {normalized}");
2104        let rules = parse_replication_rules(&normalized);
2105        assert_eq!(rules.len(), 1, "Expected 1 rule, got {}", rules.len());
2106        assert_eq!(rules[0].status, "Enabled");
2107        assert_eq!(rules[0].dest_bucket, "repl-dest");
2108    }
2109
2110    #[test]
2111    fn test_replicate_object() {
2112        use crate::state::{S3Bucket, S3State};
2113
2114        let mut state = S3State::new("123456789012", "us-east-1");
2115
2116        // Create source and destination buckets
2117        let mut src = S3Bucket::new("source", "us-east-1", "owner");
2118        src.versioning = Some("Enabled".to_string());
2119        src.replication_config = Some(
2120            "<ReplicationConfiguration>\
2121             <Rule><Status>Enabled</Status>\
2122             <Filter><Prefix></Prefix></Filter>\
2123             <Destination><Bucket>arn:aws:s3:::destination</Bucket></Destination>\
2124             </Rule></ReplicationConfiguration>"
2125                .to_string(),
2126        );
2127        let obj = S3Object {
2128            key: "test-key".to_string(),
2129            body: crate::state::memory_body(Bytes::from_static(b"hello")),
2130            content_type: "text/plain".to_string(),
2131            etag: "abc".to_string(),
2132            size: 5,
2133            last_modified: Utc::now(),
2134            storage_class: "STANDARD".to_string(),
2135            version_id: Some("v1".to_string()),
2136            ..Default::default()
2137        };
2138        src.objects.insert("test-key".to_string(), obj);
2139        state.buckets.insert("source".to_string(), src);
2140
2141        let dest = S3Bucket::new("destination", "us-east-1", "owner");
2142        state.buckets.insert("destination".to_string(), dest);
2143
2144        replicate_object(&mut state, "source", "test-key");
2145
2146        // Object should now exist in destination
2147        let dest_obj = state
2148            .buckets
2149            .get("destination")
2150            .unwrap()
2151            .objects
2152            .get("test-key");
2153        assert!(dest_obj.is_some());
2154        assert_eq!(
2155            state.read_body(&dest_obj.unwrap().body).unwrap(),
2156            Bytes::from_static(b"hello")
2157        );
2158    }
2159
2160    #[test]
2161    fn cors_header_value_does_not_panic_on_unusual_input() {
2162        // Verify that CORS header value parsing doesn't panic even with unusual strings.
2163        // HeaderValue::from_str rejects non-visible-ASCII, so our unwrap_or_else fallback
2164        // must produce a valid (empty) header value instead of panicking.
2165        let valid_origin = "https://example.com";
2166        let result: Result<http::HeaderValue, _> = valid_origin.parse();
2167        assert!(result.is_ok());
2168
2169        // Non-ASCII would fail .parse() for HeaderValue; verify fallback works
2170        let bad_origin = "https://ex\x01ample.com";
2171        let result: Result<http::HeaderValue, _> = bad_origin.parse();
2172        assert!(result.is_err());
2173        // Our production code uses unwrap_or_else to return empty HeaderValue
2174        let fallback = bad_origin
2175            .parse()
2176            .unwrap_or_else(|_| http::HeaderValue::from_static(""));
2177        assert_eq!(fallback, "");
2178    }
2179}