Skip to main content

rustfs_mcp/
s3_client.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use anyhow::{Context, Result};
16use aws_sdk_s3::config::{Credentials, Region};
17use aws_sdk_s3::primitives::ByteStream;
18use aws_sdk_s3::{Client, Config as S3Config};
19use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
20use serde::{Deserialize, Serialize};
21use std::path::Path;
22use tokio::io::AsyncWriteExt;
23use tracing::{debug, info};
24
25use crate::config::Config;
26
27/// Basic S3 bucket information.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct BucketInfo {
30    pub name: String,
31    pub creation_date: Option<String>,
32}
33
34/// Basic S3 object metadata.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ObjectInfo {
37    pub key: String,
38    pub size: Option<i64>,
39    pub last_modified: Option<String>,
40    pub etag: Option<String>,
41    pub storage_class: Option<String>,
42}
43
44/// Options for `list_objects_v2`.
45#[derive(Debug, Clone, Default)]
46pub struct ListObjectsOptions {
47    pub prefix: Option<String>,
48    pub delimiter: Option<String>,
49    pub max_keys: Option<i32>,
50    pub continuation_token: Option<String>,
51    pub start_after: Option<String>,
52}
53
54/// Result payload for `list_objects_v2`.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ListObjectsResult {
57    pub objects: Vec<ObjectInfo>,
58    pub common_prefixes: Vec<String>,
59    pub is_truncated: bool,
60    pub next_continuation_token: Option<String>,
61    pub max_keys: Option<i32>,
62    pub key_count: i32,
63}
64
65/// Options for uploading a local file to S3.
66#[derive(Debug, Clone, Default)]
67pub struct UploadFileOptions {
68    pub content_type: Option<String>,
69    pub metadata: Option<std::collections::HashMap<String, String>>,
70    pub storage_class: Option<String>,
71    pub server_side_encryption: Option<String>,
72    pub cache_control: Option<String>,
73    pub content_disposition: Option<String>,
74    pub content_encoding: Option<String>,
75    pub content_language: Option<String>,
76}
77
78/// Options for reading/downloading an object from S3.
79#[derive(Debug, Clone, Default)]
80pub struct GetObjectOptions {
81    pub version_id: Option<String>,
82    pub range: Option<String>,
83    pub if_modified_since: Option<String>,
84    pub if_unmodified_since: Option<String>,
85    pub max_content_size: Option<usize>,
86}
87
88/// File type classification used by get-object operations.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub enum DetectedFileType {
91    Text,
92    NonText(String), // mime type for non-text files
93}
94
95/// Result payload for object retrieval.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct GetObjectResult {
98    pub bucket: String,
99    pub key: String,
100    pub content_type: String,
101    pub content_length: u64,
102    pub last_modified: Option<String>,
103    pub etag: Option<String>,
104    pub version_id: Option<String>,
105    pub detected_type: DetectedFileType,
106    pub content: Option<Vec<u8>>,     // Raw content bytes
107    pub text_content: Option<String>, // UTF-8 decoded content for text files
108}
109
110/// Result payload for file upload.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct UploadResult {
113    pub bucket: String,
114    pub key: String,
115    pub etag: String,
116    pub location: String,
117    pub version_id: Option<String>,
118    pub file_size: u64,
119    pub content_type: String,
120    pub upload_id: Option<String>,
121}
122#[derive(Debug, Clone)]
123pub struct S3Client {
124    client: Client,
125}
126
127impl S3Client {
128    /// Create an S3 client from validated [`Config`].
129    pub async fn new(config: &Config) -> Result<Self> {
130        info!("Initializing S3 client from configuration");
131
132        let access_key = config.access_key_id();
133        let secret_key = config.secret_access_key();
134
135        debug!("Using AWS region: {}", config.region);
136        if let Some(ref endpoint) = config.endpoint_url {
137            debug!("Using custom endpoint: {}", endpoint);
138        }
139
140        let credentials = Credentials::new(access_key, secret_key, None, None, "rustfs-mcp-server");
141
142        let mut config_builder = S3Config::builder()
143            .credentials_provider(credentials)
144            .region(Region::new(config.region.clone()))
145            .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest());
146
147        if config
148            .endpoint_url
149            .as_deref()
150            .is_some_and(|endpoint| endpoint.starts_with("http://"))
151        {
152            config_builder =
153                config_builder.http_client(SmithyHttpClientBuilder::new().build_http());
154        }
155
156        // Set force path style if custom endpoint or explicitly requested
157        let should_force_path_style = config.endpoint_url.is_some() || config.force_path_style;
158        if should_force_path_style {
159            config_builder = config_builder.force_path_style(true);
160        }
161
162        if let Some(endpoint) = &config.endpoint_url {
163            config_builder = config_builder.endpoint_url(endpoint);
164        }
165
166        let s3_config = config_builder.build();
167        let client = Client::from_conf(s3_config);
168
169        info!("S3 client initialized successfully");
170
171        Ok(Self { client })
172    }
173
174    /// Create a bucket.
175    pub async fn create_bucket(&self, bucket_name: &str) -> Result<BucketInfo> {
176        info!("Creating S3 bucket: {}", bucket_name);
177
178        self.client
179            .create_bucket()
180            .bucket(bucket_name)
181            .send()
182            .await
183            .context(format!("Failed to create S3 bucket: {bucket_name}"))?;
184
185        info!("Bucket '{}' created successfully", bucket_name);
186        Ok(BucketInfo {
187            name: bucket_name.to_string(),
188            creation_date: None, // Creation date not returned by create_bucket
189        })
190    }
191
192    /// Delete a bucket.
193    pub async fn delete_bucket(&self, bucket_name: &str) -> Result<()> {
194        info!("Deleting S3 bucket: {}", bucket_name);
195        self.client
196            .delete_bucket()
197            .bucket(bucket_name)
198            .send()
199            .await
200            .context(format!("Failed to delete S3 bucket: {bucket_name}"))?;
201
202        info!("Bucket '{}' deleted successfully", bucket_name);
203        Ok(())
204    }
205
206    /// List all accessible buckets.
207    pub async fn list_buckets(&self) -> Result<Vec<BucketInfo>> {
208        debug!("Listing S3 buckets");
209
210        let response = self
211            .client
212            .list_buckets()
213            .send()
214            .await
215            .context("Failed to list S3 buckets")?;
216
217        let buckets: Vec<BucketInfo> = response
218            .buckets()
219            .iter()
220            .map(|bucket| {
221                let name = bucket.name().unwrap_or("unknown").to_string();
222                let creation_date = bucket.creation_date().map(|dt| {
223                    dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
224                        .unwrap()
225                });
226
227                BucketInfo {
228                    name,
229                    creation_date,
230                }
231            })
232            .collect();
233
234        debug!("Found {} buckets", buckets.len());
235        Ok(buckets)
236    }
237
238    /// List objects in a bucket with optional pagination/filtering.
239    pub async fn list_objects_v2(
240        &self,
241        bucket_name: &str,
242        options: ListObjectsOptions,
243    ) -> Result<ListObjectsResult> {
244        debug!(
245            "Listing objects in bucket '{}' with options: {:?}",
246            bucket_name, options
247        );
248
249        let mut request = self.client.list_objects_v2().bucket(bucket_name);
250
251        if let Some(prefix) = options.prefix {
252            request = request.prefix(prefix);
253        }
254
255        if let Some(delimiter) = options.delimiter {
256            request = request.delimiter(delimiter);
257        }
258
259        if let Some(max_keys) = options.max_keys {
260            request = request.max_keys(max_keys);
261        }
262
263        if let Some(continuation_token) = options.continuation_token {
264            request = request.continuation_token(continuation_token);
265        }
266
267        if let Some(start_after) = options.start_after {
268            request = request.start_after(start_after);
269        }
270
271        let response = request
272            .send()
273            .await
274            .context(format!("Failed to list objects in bucket '{bucket_name}'"))?;
275
276        let objects: Vec<ObjectInfo> = response
277            .contents()
278            .iter()
279            .map(|obj| {
280                let key = obj.key().unwrap_or("unknown").to_string();
281                let size = obj.size();
282                let last_modified = obj.last_modified().map(|dt| {
283                    dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
284                        .unwrap()
285                });
286                let etag = obj.e_tag().map(|e| e.to_string());
287                let storage_class = obj.storage_class().map(|sc| sc.as_str().to_string());
288
289                ObjectInfo {
290                    key,
291                    size,
292                    last_modified,
293                    etag,
294                    storage_class,
295                }
296            })
297            .collect();
298
299        let common_prefixes: Vec<String> = response
300            .common_prefixes()
301            .iter()
302            .filter_map(|cp| cp.prefix())
303            .map(|p| p.to_string())
304            .collect();
305
306        let result = ListObjectsResult {
307            objects,
308            common_prefixes,
309            is_truncated: response.is_truncated().unwrap_or(false),
310            next_continuation_token: response.next_continuation_token().map(|t| t.to_string()),
311            max_keys: response.max_keys(),
312            key_count: response.key_count().unwrap_or(0),
313        };
314
315        debug!(
316            "Found {} objects and {} common prefixes in bucket '{}'",
317            result.objects.len(),
318            result.common_prefixes.len(),
319            bucket_name
320        );
321
322        Ok(result)
323    }
324
325    /// Upload a local file to S3.
326    pub async fn upload_file(
327        &self,
328        local_path: &str,
329        bucket_name: &str,
330        object_key: &str,
331        options: UploadFileOptions,
332    ) -> Result<UploadResult> {
333        info!(
334            "Starting file upload: '{}' -> s3://{}/{}",
335            local_path, bucket_name, object_key
336        );
337
338        let path = Path::new(local_path);
339        let canonical_path = path
340            .canonicalize()
341            .context(format!("Failed to resolve file path: {local_path}"))?;
342
343        if !canonical_path.exists() {
344            anyhow::bail!("File does not exist: {local_path}");
345        }
346
347        if !canonical_path.is_file() {
348            anyhow::bail!("Path is not a file: {local_path}");
349        }
350
351        let metadata = tokio::fs::metadata(&canonical_path)
352            .await
353            .context(format!("Failed to read file metadata: {local_path}"))?;
354
355        let file_size = metadata.len();
356        debug!("File size: {file_size} bytes");
357
358        let content_type = options.content_type.unwrap_or_else(|| {
359            let detected = mime_guess::from_path(&canonical_path)
360                .first_or_octet_stream()
361                .to_string();
362            debug!("Auto-detected content type: {detected}");
363            detected
364        });
365
366        let file_content = tokio::fs::read(&canonical_path)
367            .await
368            .context(format!("Failed to read file content: {local_path}"))?;
369
370        let byte_stream = ByteStream::from(file_content);
371
372        let mut request = self
373            .client
374            .put_object()
375            .bucket(bucket_name)
376            .key(object_key)
377            .body(byte_stream)
378            .content_type(&content_type)
379            .content_length(file_size as i64);
380
381        if let Some(storage_class) = &options.storage_class {
382            request = request.storage_class(storage_class.as_str().into());
383        }
384
385        if let Some(cache_control) = &options.cache_control {
386            request = request.cache_control(cache_control);
387        }
388
389        if let Some(content_disposition) = &options.content_disposition {
390            request = request.content_disposition(content_disposition);
391        }
392
393        if let Some(content_encoding) = &options.content_encoding {
394            request = request.content_encoding(content_encoding);
395        }
396
397        if let Some(content_language) = &options.content_language {
398            request = request.content_language(content_language);
399        }
400
401        if let Some(sse) = &options.server_side_encryption {
402            request = request.server_side_encryption(sse.as_str().into());
403        }
404
405        if let Some(metadata_map) = &options.metadata {
406            for (key, value) in metadata_map {
407                request = request.metadata(key, value);
408            }
409        }
410
411        debug!("Executing S3 put_object request");
412        let response = request.send().await.context(format!(
413            "Failed to upload file to s3://{bucket_name}/{object_key}"
414        ))?;
415
416        let etag = response.e_tag().unwrap_or("unknown").to_string();
417        let version_id = response.version_id().map(|v| v.to_string());
418
419        let location = format!("s3://{bucket_name}/{object_key}");
420
421        let upload_result = UploadResult {
422            bucket: bucket_name.to_string(),
423            key: object_key.to_string(),
424            etag,
425            location,
426            version_id,
427            file_size,
428            content_type,
429            upload_id: None,
430        };
431
432        info!(
433            "File upload completed successfully: {} bytes uploaded to s3://{}/{}",
434            file_size, bucket_name, object_key
435        );
436
437        Ok(upload_result)
438    }
439
440    /// Fetch object content and metadata from S3.
441    pub async fn get_object(
442        &self,
443        bucket_name: &str,
444        object_key: &str,
445        options: GetObjectOptions,
446    ) -> Result<GetObjectResult> {
447        info!("Getting object: s3://{}/{}", bucket_name, object_key);
448
449        let mut request = self.client.get_object().bucket(bucket_name).key(object_key);
450
451        if let Some(version_id) = &options.version_id {
452            request = request.version_id(version_id);
453        }
454
455        if let Some(range) = &options.range {
456            request = request.range(range);
457        }
458
459        if let Some(if_modified_since) = &options.if_modified_since {
460            request = request.if_modified_since(
461                aws_sdk_s3::primitives::DateTime::from_str(
462                    if_modified_since,
463                    aws_sdk_s3::primitives::DateTimeFormat::DateTime,
464                )
465                .context("Failed to parse if_modified_since date")?,
466            );
467        }
468
469        debug!("Executing S3 get_object request");
470        let response = request.send().await.context(format!(
471            "Failed to get object from s3://{bucket_name}/{object_key}"
472        ))?;
473
474        let content_type = response
475            .content_type()
476            .unwrap_or("application/octet-stream")
477            .to_string();
478        let content_length = response.content_length().unwrap_or(0) as u64;
479        let last_modified = response.last_modified().map(|dt| {
480            dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
481                .unwrap()
482        });
483        let etag = response.e_tag().map(|e| e.to_string());
484        let version_id = response.version_id().map(|v| v.to_string());
485
486        let max_size = options.max_content_size.unwrap_or(10 * 1024 * 1024);
487        let mut content = Vec::new();
488        let mut byte_stream = response.body;
489        let mut total_read = 0;
490
491        while let Some(bytes_result) = byte_stream
492            .try_next()
493            .await
494            .context("Failed to read object content")?
495        {
496            if total_read + bytes_result.len() > max_size {
497                anyhow::bail!("Object size exceeds maximum allowed size of {max_size} bytes");
498            }
499            content.extend_from_slice(&bytes_result);
500            total_read += bytes_result.len();
501        }
502
503        debug!("Read {} bytes from object", content.len());
504
505        let detected_type = Self::detect_file_type(Some(&content_type), &content);
506        debug!("Detected file type: {detected_type:?}");
507
508        let text_content = match &detected_type {
509            DetectedFileType::Text => match std::str::from_utf8(&content) {
510                Ok(text) => Some(text.to_string()),
511                Err(_) => {
512                    debug!("Failed to decode content as UTF-8, treating as binary");
513                    None
514                }
515            },
516            _ => None,
517        };
518
519        let result = GetObjectResult {
520            bucket: bucket_name.to_string(),
521            key: object_key.to_string(),
522            content_type,
523            content_length,
524            last_modified,
525            etag,
526            version_id,
527            detected_type,
528            content: Some(content),
529            text_content,
530        };
531
532        info!(
533            "Object retrieved successfully: {} bytes from s3://{}/{}",
534            result.content_length, bucket_name, object_key
535        );
536
537        Ok(result)
538    }
539
540    fn detect_file_type(content_type: Option<&str>, content_bytes: &[u8]) -> DetectedFileType {
541        if let Some(ct) = content_type {
542            let ct_lower = ct.to_lowercase();
543
544            if ct_lower.starts_with("text/")
545                || ct_lower == "application/json"
546                || ct_lower == "application/xml"
547                || ct_lower == "application/yaml"
548                || ct_lower == "application/javascript"
549                || ct_lower == "application/x-yaml"
550                || ct_lower == "application/x-sh"
551                || ct_lower == "application/x-shellscript"
552                || ct_lower.contains("script")
553                || ct_lower.contains("xml")
554                || ct_lower.contains("json")
555            {
556                return DetectedFileType::Text;
557            }
558
559            return DetectedFileType::NonText(ct.to_string());
560        }
561
562        if content_bytes.len() >= 4 {
563            match &content_bytes[0..4] {
564                // PNG: 89 50 4E 47
565                [0x89, 0x50, 0x4E, 0x47] => {
566                    return DetectedFileType::NonText("image/png".to_string());
567                }
568                // JPEG: FF D8 FF
569                [0xFF, 0xD8, 0xFF, _] => {
570                    return DetectedFileType::NonText("image/jpeg".to_string());
571                }
572                // GIF: 47 49 46 38
573                [0x47, 0x49, 0x46, 0x38] => {
574                    return DetectedFileType::NonText("image/gif".to_string());
575                }
576                // BMP: 42 4D
577                [0x42, 0x4D, _, _] => return DetectedFileType::NonText("image/bmp".to_string()),
578                // RIFF container (WebP/WAV)
579                [0x52, 0x49, 0x46, 0x46] if content_bytes.len() >= 12 => {
580                    if &content_bytes[8..12] == b"WEBP" {
581                        return DetectedFileType::NonText("image/webp".to_string());
582                    } else if &content_bytes[8..12] == b"WAVE" {
583                        return DetectedFileType::NonText("audio/wav".to_string());
584                    }
585                    return DetectedFileType::NonText("application/octet-stream".to_string());
586                }
587                _ => {}
588            }
589        }
590
591        // 3. Check if content is valid UTF-8 text as fallback
592        if std::str::from_utf8(content_bytes).is_ok() {
593            // Additional heuristics for text detection
594            let non_printable_count = content_bytes
595                .iter()
596                .filter(|&&b| b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) // Control chars except tab, LF, CR
597                .count();
598            let total_chars = content_bytes.len();
599
600            // If less than 5% are non-printable control characters, consider it text
601            if total_chars > 0 && (non_printable_count as f64 / total_chars as f64) < 0.05 {
602                return DetectedFileType::Text;
603            }
604        }
605
606        // Default to non-text binary
607        DetectedFileType::NonText("application/octet-stream".to_string())
608    }
609
610    /// Download an S3 object directly to a local file path.
611    pub async fn download_object_to_file(
612        &self,
613        bucket_name: &str,
614        object_key: &str,
615        local_path: &str,
616        options: GetObjectOptions,
617    ) -> Result<(u64, String)> {
618        info!(
619            "Downloading object: s3://{}/{} -> {}",
620            bucket_name, object_key, local_path
621        );
622
623        let mut request = self.client.get_object().bucket(bucket_name).key(object_key);
624
625        if let Some(version_id) = &options.version_id {
626            request = request.version_id(version_id);
627        }
628
629        if let Some(range) = &options.range {
630            request = request.range(range);
631        }
632
633        if let Some(if_modified_since) = &options.if_modified_since {
634            request = request.if_modified_since(
635                aws_sdk_s3::primitives::DateTime::from_str(
636                    if_modified_since,
637                    aws_sdk_s3::primitives::DateTimeFormat::DateTime,
638                )
639                .context("Failed to parse if_modified_since date")?,
640            );
641        }
642
643        debug!("Executing S3 get_object request for download");
644        let response = request.send().await.context(format!(
645            "Failed to get object from s3://{bucket_name}/{object_key}"
646        ))?;
647
648        let local_file_path = Path::new(local_path);
649
650        if let Some(parent) = local_file_path.parent() {
651            tokio::fs::create_dir_all(parent).await.context(format!(
652                "Failed to create parent directories for {local_path}"
653            ))?;
654        }
655
656        let mut file = tokio::fs::File::create(local_file_path)
657            .await
658            .context(format!("Failed to create local file: {local_path}"))?;
659
660        let mut byte_stream = response.body;
661        let mut total_bytes = 0u64;
662
663        while let Some(bytes_result) = byte_stream
664            .try_next()
665            .await
666            .context("Failed to read object content")?
667        {
668            file.write_all(&bytes_result)
669                .await
670                .context(format!("Failed to write to local file: {local_path}"))?;
671            total_bytes += bytes_result.len() as u64;
672        }
673
674        file.flush().await.context("Failed to flush file to disk")?;
675
676        let absolute_path = local_file_path
677            .canonicalize()
678            .unwrap_or_else(|_| local_file_path.to_path_buf())
679            .to_string_lossy()
680            .to_string();
681
682        info!(
683            "Object downloaded successfully: {} bytes from s3://{}/{} to {}",
684            total_bytes, bucket_name, object_key, absolute_path
685        );
686
687        Ok((total_bytes, absolute_path))
688    }
689
690    /// Verify S3 connectivity by performing a lightweight list-buckets call.
691    pub async fn health_check(&self) -> Result<()> {
692        debug!("Performing S3 health check");
693
694        self.client
695            .list_buckets()
696            .send()
697            .await
698            .context("S3 health check failed")?;
699
700        debug!("S3 health check passed");
701        Ok(())
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[tokio::test]
710    #[ignore] // Requires AWS credentials
711    async fn test_s3_client_creation() {
712        let config = Config {
713            access_key_id: Some("test_key".to_string()),
714            secret_access_key: Some("test_secret".to_string()),
715            region: "us-east-1".to_string(),
716            ..Config::default()
717        };
718
719        let result = S3Client::new(&config).await;
720        assert!(result.is_ok());
721    }
722
723    #[test]
724    fn test_bucket_info_serialization() {
725        let bucket = BucketInfo {
726            name: "test-bucket".to_string(),
727            creation_date: Some("2024-01-01T00:00:00Z".to_string()),
728        };
729
730        let json = serde_json::to_string(&bucket).unwrap();
731        let deserialized: BucketInfo = serde_json::from_str(&json).unwrap();
732
733        assert_eq!(bucket.name, deserialized.name);
734        assert_eq!(bucket.creation_date, deserialized.creation_date);
735    }
736
737    #[test]
738    fn test_detect_file_type_text_content_type() {
739        let test_cases = vec![
740            ("text/plain", "Hello world"),
741            ("text/html", "<html></html>"),
742            ("application/json", r#"{"key": "value"}"#),
743            ("application/xml", "<xml></xml>"),
744            ("application/yaml", "key: value"),
745            ("application/javascript", "console.log('hello');"),
746        ];
747
748        for (content_type, content) in test_cases {
749            let result = S3Client::detect_file_type(Some(content_type), content.as_bytes());
750            match result {
751                DetectedFileType::Text => {}
752                _ => panic!("Expected Text for content type {content_type}"),
753            }
754        }
755    }
756
757    #[test]
758    fn test_detect_file_type_non_text_content_type() {
759        // Test various non-text content types
760        let test_cases = vec![
761            ("image/png", "image/png"),
762            ("image/jpeg", "image/jpeg"),
763            ("audio/mp3", "audio/mp3"),
764            ("video/mp4", "video/mp4"),
765            ("application/pdf", "application/pdf"),
766        ];
767
768        for (content_type, expected_mime) in test_cases {
769            let result = S3Client::detect_file_type(Some(content_type), b"some content");
770            match result {
771                DetectedFileType::NonText(mime_type) => {
772                    assert_eq!(mime_type, expected_mime);
773                }
774                _ => panic!("Expected NonText for content type {content_type}"),
775            }
776        }
777    }
778
779    #[test]
780    fn test_detect_file_type_magic_bytes_simplified() {
781        // Test magic bytes detection (now all return NonText)
782        let test_cases = vec![
783            // PNG magic bytes: 89 50 4E 47
784            (
785                vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
786                "image/png",
787            ),
788            // JPEG magic bytes: FF D8 FF
789            (vec![0xFF, 0xD8, 0xFF, 0xE0], "image/jpeg"),
790            // GIF magic bytes: 47 49 46 38
791            (vec![0x47, 0x49, 0x46, 0x38, 0x37, 0x61], "image/gif"),
792        ];
793
794        for (content, expected_mime) in test_cases {
795            let result = S3Client::detect_file_type(None, &content);
796            match result {
797                DetectedFileType::NonText(mime_type) => {
798                    assert_eq!(mime_type, expected_mime);
799                }
800                _ => panic!("Expected NonText for magic bytes: {content:?}"),
801            }
802        }
803    }
804
805    #[test]
806    fn test_detect_file_type_webp_magic_bytes() {
807        // WebP has more complex magic bytes: RIFF....WEBP
808        let mut webp_content = vec![0x52, 0x49, 0x46, 0x46]; // RIFF
809        webp_content.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Size (4 bytes)
810        webp_content.extend_from_slice(b"WEBP"); // WEBP signature
811
812        let result = S3Client::detect_file_type(None, &webp_content);
813        match result {
814            DetectedFileType::NonText(mime_type) => {
815                assert_eq!(mime_type, "image/webp");
816            }
817            _ => panic!("Expected WebP NonText detection"),
818        }
819    }
820
821    #[test]
822    fn test_detect_file_type_wav_magic_bytes() {
823        // WAV has magic bytes: RIFF....WAVE
824        let mut wav_content = vec![0x52, 0x49, 0x46, 0x46]; // RIFF
825        wav_content.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Size (4 bytes)
826        wav_content.extend_from_slice(b"WAVE"); // WAVE signature
827
828        let result = S3Client::detect_file_type(None, &wav_content);
829        match result {
830            DetectedFileType::NonText(mime_type) => {
831                assert_eq!(mime_type, "audio/wav");
832            }
833            _ => panic!("Expected WAV NonText detection"),
834        }
835    }
836
837    #[test]
838    fn test_detect_file_type_utf8_text() {
839        // Test UTF-8 text detection
840        let utf8_content = "Hello, World! 🌍".as_bytes();
841        let result = S3Client::detect_file_type(None, utf8_content);
842        match result {
843            DetectedFileType::Text => {}
844            _ => panic!("Expected Text for UTF-8 content"),
845        }
846
847        // Test ASCII text
848        let ascii_content = b"Hello, world! This is ASCII text.";
849        let result = S3Client::detect_file_type(None, ascii_content);
850        match result {
851            DetectedFileType::Text => {}
852            _ => panic!("Expected Text for ASCII content"),
853        }
854    }
855
856    #[test]
857    fn test_detect_file_type_binary() {
858        // Test binary content that should not be detected as text
859        let binary_content = vec![0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD, 0xFC];
860        let result = S3Client::detect_file_type(None, &binary_content);
861        match result {
862            DetectedFileType::NonText(mime_type) => {
863                assert_eq!(mime_type, "application/octet-stream");
864            }
865            _ => panic!("Expected NonText for binary content"),
866        }
867    }
868
869    #[test]
870    fn test_detect_file_type_priority() {
871        // Content-Type should take priority over magic bytes
872        let png_magic_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
873
874        // Even with PNG magic bytes, text content-type should win
875        let result = S3Client::detect_file_type(Some("text/plain"), &png_magic_bytes);
876        match result {
877            DetectedFileType::Text => {}
878            _ => panic!("Expected Text due to content-type priority"),
879        }
880    }
881
882    #[test]
883    fn test_get_object_options_default() {
884        let options = GetObjectOptions::default();
885        assert!(options.version_id.is_none());
886        assert!(options.range.is_none());
887        assert!(options.if_modified_since.is_none());
888        assert!(options.if_unmodified_since.is_none());
889        assert!(options.max_content_size.is_none());
890    }
891
892    #[test]
893    fn test_detected_file_type_serialization() {
894        let test_cases = vec![
895            DetectedFileType::Text,
896            DetectedFileType::NonText("image/png".to_string()),
897            DetectedFileType::NonText("audio/mpeg".to_string()),
898            DetectedFileType::NonText("application/octet-stream".to_string()),
899        ];
900
901        for file_type in test_cases {
902            let json = serde_json::to_string(&file_type).unwrap();
903            let deserialized: DetectedFileType = serde_json::from_str(&json).unwrap();
904
905            match (&file_type, &deserialized) {
906                (DetectedFileType::Text, DetectedFileType::Text) => {}
907                (DetectedFileType::NonText(a), DetectedFileType::NonText(b)) => assert_eq!(a, b),
908                _ => panic!("Serialization/deserialization mismatch"),
909            }
910        }
911    }
912}