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