Skip to main content

rustfs_cli/commands/
cp.rs

1//! cp command - Copy objects
2//!
3//! Copies objects between local filesystem and S3, or between S3 locations.
4
5use clap::Args;
6use rc_core::{
7    AliasManager, ObjectEncryptionRequest, ObjectStore as _, ParsedPath, RemotePath, parse_path,
8};
9use rc_s3::S3Client;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12
13use crate::exit_code::ExitCode;
14use crate::output::{Formatter, OutputConfig, ProgressBar};
15
16const CP_AFTER_HELP: &str = "\
17Examples:
18  rc object copy ./report.json local/my-bucket/reports/
19  rc cp ./report.json local/my-bucket/reports/
20  rc object copy local/source-bucket/archive.tar.gz ./downloads/archive.tar.gz";
21
22const REMOTE_PATH_SUGGESTION: &str =
23    "Use a local filesystem path or a remote path in the form alias/bucket[/key].";
24
25/// Copy objects
26#[derive(Args, Debug)]
27#[command(after_help = CP_AFTER_HELP)]
28pub struct CpArgs {
29    /// Source path (local path or alias/bucket/key)
30    pub source: String,
31
32    /// Destination path (local path or alias/bucket/key)
33    pub target: String,
34
35    /// Copy recursively
36    #[arg(short, long)]
37    pub recursive: bool,
38
39    /// Preserve file attributes
40    #[arg(short, long)]
41    pub preserve: bool,
42
43    /// Continue on errors
44    #[arg(long)]
45    pub continue_on_error: bool,
46
47    /// Overwrite destination if it exists
48    #[arg(long, default_value = "true")]
49    pub overwrite: bool,
50
51    /// Only show what would be copied (dry run)
52    #[arg(long)]
53    pub dry_run: bool,
54
55    /// Storage class for destination (S3 only)
56    #[arg(long)]
57    pub storage_class: Option<String>,
58
59    /// Content type for uploaded files
60    #[arg(long)]
61    pub content_type: Option<String>,
62
63    /// Apply SSE-S3 to the remote destination path
64    #[arg(long = "enc-s3")]
65    pub enc_s3: Vec<String>,
66
67    /// Apply SSE-KMS to the remote destination path as TARGET=KMS_KEY_ID
68    #[arg(long = "enc-kms")]
69    pub enc_kms: Vec<String>,
70}
71
72#[derive(Debug, Serialize)]
73struct CpOutput {
74    status: &'static str,
75    source: String,
76    target: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    size_bytes: Option<i64>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    size_human: Option<String>,
81}
82
83/// Execute the cp command
84pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
85    let formatter = Formatter::new(output_config);
86    let alias_manager = AliasManager::new().ok();
87
88    // Parse source and target paths
89    let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
90        Ok(p) => p,
91        Err(e) => {
92            return formatter.fail_with_suggestion(
93                ExitCode::UsageError,
94                &format!("Invalid source path: {e}"),
95                REMOTE_PATH_SUGGESTION,
96            );
97        }
98    };
99
100    let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
101        Ok(p) => p,
102        Err(e) => {
103            return formatter.fail_with_suggestion(
104                ExitCode::UsageError,
105                &format!("Invalid target path: {e}"),
106                REMOTE_PATH_SUGGESTION,
107            );
108        }
109    };
110
111    // Determine copy direction
112    match (&source, &target) {
113        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
114            // Local to S3
115            copy_local_to_s3(src, dst, &args, &formatter).await
116        }
117        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
118            // S3 to Local
119            copy_s3_to_local(src, dst, &args, &formatter).await
120        }
121        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
122            // S3 to S3
123            copy_s3_to_s3(src, dst, &args, &formatter).await
124        }
125        (ParsedPath::Local(_), ParsedPath::Local(_)) => formatter.fail_with_suggestion(
126            ExitCode::UsageError,
127            "Cannot copy between two local paths. Use system cp command.",
128            "Use your local shell cp command when both paths are on the filesystem.",
129        ),
130    }
131}
132
133fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
134    let parsed = parse_path(path)?;
135
136    let ParsedPath::Remote(remote) = &parsed else {
137        return Ok(parsed);
138    };
139
140    if let Some(manager) = alias_manager
141        && matches!(manager.exists(&remote.alias), Ok(true))
142    {
143        return Ok(parsed);
144    }
145
146    if Path::new(path).exists() {
147        return Ok(ParsedPath::Local(PathBuf::from(path)));
148    }
149
150    Ok(parsed)
151}
152
153async fn copy_local_to_s3(
154    src: &Path,
155    dst: &RemotePath,
156    args: &CpArgs,
157    formatter: &Formatter,
158) -> ExitCode {
159    let target = ParsedPath::Remote(dst.clone());
160    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
161        Ok(encryption) => encryption,
162        Err(error) => {
163            return formatter.fail(ExitCode::UsageError, &error);
164        }
165    };
166
167    // Check if source exists
168    if !src.exists() {
169        return formatter.fail_with_suggestion(
170            ExitCode::NotFound,
171            &format!("Source not found: {}", src.display()),
172            "Check the local source path and retry the copy command.",
173        );
174    }
175
176    // If source is a directory, require recursive flag
177    if src.is_dir() && !args.recursive {
178        return formatter.fail_with_suggestion(
179            ExitCode::UsageError,
180            "Source is a directory. Use -r/--recursive to copy directories.",
181            "Retry with -r or --recursive to copy a directory tree.",
182        );
183    }
184
185    // Load alias and create client
186    let alias_manager = match AliasManager::new() {
187        Ok(am) => am,
188        Err(e) => {
189            formatter.error(&format!("Failed to load aliases: {e}"));
190            return ExitCode::GeneralError;
191        }
192    };
193
194    let alias = match alias_manager.get(&dst.alias) {
195        Ok(a) => a,
196        Err(_) => {
197            return formatter.fail_with_suggestion(
198                ExitCode::NotFound,
199                &format!("Alias '{}' not found", dst.alias),
200                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
201            );
202        }
203    };
204
205    let client = match S3Client::new(alias).await {
206        Ok(c) => c,
207        Err(e) => {
208            return formatter.fail(
209                ExitCode::NetworkError,
210                &format!("Failed to create S3 client: {e}"),
211            );
212        }
213    };
214
215    if src.is_file() {
216        // Single file upload
217        upload_file(&client, src, dst, args, formatter, encryption.as_ref()).await
218    } else {
219        // Directory upload
220        upload_directory(&client, src, dst, args, formatter, encryption.as_ref()).await
221    }
222}
223
224/// Multipart upload threshold: files larger than this size use multipart upload.
225const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
226/// Download progress threshold: avoid flicker for tiny downloads while surfacing meaningful waits.
227const DOWNLOAD_PROGRESS_THRESHOLD: u64 = 4 * 1024 * 1024;
228
229fn update_download_progress(
230    progress: &mut Option<ProgressBar>,
231    output_config: &OutputConfig,
232    bytes_downloaded: u64,
233    total_size: Option<u64>,
234) {
235    let Some(total_size) = total_size else {
236        return;
237    };
238
239    if total_size < DOWNLOAD_PROGRESS_THRESHOLD {
240        return;
241    }
242
243    let progress_bar =
244        progress.get_or_insert_with(|| ProgressBar::new(output_config.clone(), total_size));
245    progress_bar.set_position(bytes_downloaded);
246}
247
248fn print_upload_success(
249    formatter: &Formatter,
250    info: &rc_core::ObjectInfo,
251    src_display: &str,
252    dst_display: &str,
253) {
254    if formatter.is_json() {
255        let output = CpOutput {
256            status: "success",
257            source: src_display.to_string(),
258            target: dst_display.to_string(),
259            size_bytes: info.size_bytes,
260            size_human: info.size_human.clone(),
261        };
262        formatter.json(&output);
263    } else {
264        let styled_src = formatter.style_file(src_display);
265        let styled_dst = formatter.style_file(dst_display);
266        let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
267        formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
268    }
269}
270
271async fn upload_file(
272    client: &S3Client,
273    src: &Path,
274    dst: &RemotePath,
275    args: &CpArgs,
276    formatter: &Formatter,
277    encryption: Option<&ObjectEncryptionRequest>,
278) -> ExitCode {
279    // Determine destination key
280    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
281        // If destination is a directory, use source filename
282        let filename = src.file_name().unwrap_or_default().to_string_lossy();
283        format!("{}{}", dst.key, filename)
284    } else {
285        dst.key.clone()
286    };
287
288    let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
289    let src_display = src.display().to_string();
290    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
291
292    if args.dry_run {
293        let styled_src = formatter.style_file(&src_display);
294        let styled_dst = formatter.style_file(&dst_display);
295        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
296        return ExitCode::Success;
297    }
298
299    // Get file size for progress bar decision
300    let file_size = match std::fs::metadata(src) {
301        Ok(m) => m.len(),
302        Err(e) => {
303            return formatter.fail(
304                ExitCode::GeneralError,
305                &format!("Failed to read {src_display}: {e}"),
306            );
307        }
308    };
309
310    // Determine content type
311    let guessed_type: Option<String> = mime_guess::from_path(src)
312        .first()
313        .map(|m| m.essence_str().to_string());
314    let content_type = select_upload_content_type(
315        args.content_type.as_deref(),
316        guessed_type.as_deref(),
317        file_size,
318    );
319
320    // Show progress bar for large files
321    let progress = if file_size > MULTIPART_THRESHOLD {
322        tracing::debug!(
323            file_size,
324            threshold = MULTIPART_THRESHOLD,
325            "Using multipart upload for large file"
326        );
327        Some(ProgressBar::new(formatter.output_config(), file_size))
328    } else {
329        tracing::debug!(file_size, "Using single put_object for small file");
330        None
331    };
332
333    // Upload
334    match client
335        .put_object_from_path(&target, src, content_type, encryption, |bytes_sent| {
336            if let Some(ref pb) = progress {
337                pb.set_position(bytes_sent);
338            }
339        })
340        .await
341    {
342        Ok(info) => {
343            if let Some(ref pb) = progress {
344                pb.finish_and_clear();
345            }
346            print_upload_success(formatter, &info, &src_display, &dst_display);
347            ExitCode::Success
348        }
349        Err(e) => {
350            if let Some(ref pb) = progress {
351                pb.finish_and_clear();
352            }
353            formatter.fail(
354                ExitCode::NetworkError,
355                &format!("Failed to upload {src_display}: {e}"),
356            )
357        }
358    }
359}
360
361fn select_upload_content_type<'a>(
362    explicit_type: Option<&'a str>,
363    guessed_type: Option<&'a str>,
364    file_size: u64,
365) -> Option<&'a str> {
366    if file_size > MULTIPART_THRESHOLD {
367        explicit_type
368    } else {
369        explicit_type.or(guessed_type)
370    }
371}
372
373async fn upload_directory(
374    client: &S3Client,
375    src: &Path,
376    dst: &RemotePath,
377    args: &CpArgs,
378    formatter: &Formatter,
379    encryption: Option<&ObjectEncryptionRequest>,
380) -> ExitCode {
381    use std::fs;
382
383    let mut success_count = 0;
384    let mut error_count = 0;
385
386    // Walk directory
387    fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
388        let mut files = Vec::new();
389        for entry in fs::read_dir(dir)? {
390            let entry = entry?;
391            let path = entry.path();
392            if path.is_file() {
393                let relative = path.strip_prefix(base).unwrap_or(&path);
394                let relative_str = relative.to_string_lossy().to_string();
395                files.push((path, relative_str));
396            } else if path.is_dir() {
397                files.extend(walk_dir(&path, base)?);
398            }
399        }
400        Ok(files)
401    }
402
403    let files = match walk_dir(src, src) {
404        Ok(f) => f,
405        Err(e) => {
406            return formatter.fail(
407                ExitCode::GeneralError,
408                &format!("Failed to read directory: {e}"),
409            );
410        }
411    };
412
413    for (file_path, relative_path) in files {
414        // Build destination key
415        let dst_key = if dst.key.is_empty() {
416            relative_path.replace('\\', "/")
417        } else if dst.key.ends_with('/') {
418            format!("{}{}", dst.key, relative_path.replace('\\', "/"))
419        } else {
420            format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
421        };
422
423        let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
424
425        let result = upload_file(client, &file_path, &target, args, formatter, encryption).await;
426
427        if result == ExitCode::Success {
428            success_count += 1;
429        } else {
430            error_count += 1;
431            if !args.continue_on_error {
432                return result;
433            }
434        }
435    }
436
437    if error_count > 0 {
438        formatter.warning(&format!(
439            "Completed with errors: {success_count} succeeded, {error_count} failed"
440        ));
441        ExitCode::GeneralError
442    } else {
443        if !formatter.is_json() {
444            formatter.success(&format!("Uploaded {success_count} file(s)."));
445        }
446        ExitCode::Success
447    }
448}
449
450async fn copy_s3_to_local(
451    src: &RemotePath,
452    dst: &Path,
453    args: &CpArgs,
454    formatter: &Formatter,
455) -> ExitCode {
456    // Load alias and create client
457    let alias_manager = match AliasManager::new() {
458        Ok(am) => am,
459        Err(e) => {
460            formatter.error(&format!("Failed to load aliases: {e}"));
461            return ExitCode::GeneralError;
462        }
463    };
464
465    let alias = match alias_manager.get(&src.alias) {
466        Ok(a) => a,
467        Err(_) => {
468            return formatter.fail_with_suggestion(
469                ExitCode::NotFound,
470                &format!("Alias '{}' not found", src.alias),
471                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
472            );
473        }
474    };
475
476    let client = match S3Client::new(alias).await {
477        Ok(c) => c,
478        Err(e) => {
479            return formatter.fail(
480                ExitCode::NetworkError,
481                &format!("Failed to create S3 client: {e}"),
482            );
483        }
484    };
485
486    // Check if source is a prefix (directory-like)
487    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
488
489    if is_prefix || args.recursive {
490        // Download multiple objects
491        download_prefix(&client, src, dst, args, formatter).await
492    } else {
493        // Download single object
494        download_file(&client, src, dst, args, formatter).await
495    }
496}
497
498async fn download_file(
499    client: &S3Client,
500    src: &RemotePath,
501    dst: &Path,
502    args: &CpArgs,
503    formatter: &Formatter,
504) -> ExitCode {
505    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
506
507    // Determine destination path
508    let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
509        let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
510        dst.join(filename)
511    } else {
512        dst.to_path_buf()
513    };
514
515    let dst_display = dst_path.display().to_string();
516
517    if args.dry_run {
518        let styled_src = formatter.style_file(&src_display);
519        let styled_dst = formatter.style_file(&dst_display);
520        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
521        return ExitCode::Success;
522    }
523
524    // Check if destination exists
525    if dst_path.exists() && !args.overwrite {
526        return formatter.fail_with_suggestion(
527            ExitCode::Conflict,
528            &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
529            "Retry with --overwrite if replacing the destination file is intended.",
530        );
531    }
532
533    // Create parent directories
534    if let Some(parent) = dst_path.parent()
535        && !parent.exists()
536        && let Err(e) = std::fs::create_dir_all(parent)
537    {
538        return formatter.fail(
539            ExitCode::GeneralError,
540            &format!("Failed to create directory: {e}"),
541        );
542    }
543
544    let output_config = formatter.output_config();
545    let mut progress = None;
546
547    // Download object
548    let result = client
549        .get_object_with_progress(src, |bytes_downloaded, total_size| {
550            update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
551        })
552        .await;
553
554    if let Some(ref pb) = progress {
555        pb.finish_and_clear();
556    }
557
558    match result {
559        Ok(data) => {
560            let size = data.len() as i64;
561
562            if let Err(e) = std::fs::write(&dst_path, &data) {
563                return formatter.fail(
564                    ExitCode::GeneralError,
565                    &format!("Failed to write {dst_display}: {e}"),
566                );
567            }
568
569            if formatter.is_json() {
570                let output = CpOutput {
571                    status: "success",
572                    source: src_display,
573                    target: dst_display,
574                    size_bytes: Some(size),
575                    size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
576                };
577                formatter.json(&output);
578            } else {
579                let styled_src = formatter.style_file(&src_display);
580                let styled_dst = formatter.style_file(&dst_display);
581                let styled_size =
582                    formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
583                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
584            }
585            ExitCode::Success
586        }
587        Err(e) => {
588            let err_str = e.to_string();
589            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
590                formatter.fail_with_suggestion(
591                    ExitCode::NotFound,
592                    &format!("Object not found: {src_display}"),
593                    "Check the object key and bucket path, then retry the copy command.",
594                )
595            } else {
596                formatter.fail(
597                    ExitCode::NetworkError,
598                    &format!("Failed to download {src_display}: {e}"),
599                )
600            }
601        }
602    }
603}
604
605async fn download_prefix(
606    client: &S3Client,
607    src: &RemotePath,
608    dst: &Path,
609    args: &CpArgs,
610    formatter: &Formatter,
611) -> ExitCode {
612    use rc_core::ListOptions;
613
614    let mut success_count = 0;
615    let mut error_count = 0;
616    let mut continuation_token: Option<String> = None;
617
618    loop {
619        let options = ListOptions {
620            recursive: true,
621            max_keys: Some(1000),
622            continuation_token: continuation_token.clone(),
623            ..Default::default()
624        };
625
626        match client.list_objects(src, options).await {
627            Ok(result) => {
628                for item in result.items {
629                    if item.is_dir {
630                        continue;
631                    }
632
633                    // Calculate relative path from prefix
634                    let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
635                    let dst_path =
636                        dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
637
638                    let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
639                    let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
640
641                    if result == ExitCode::Success {
642                        success_count += 1;
643                    } else {
644                        error_count += 1;
645                        if !args.continue_on_error {
646                            return result;
647                        }
648                    }
649                }
650
651                if result.truncated {
652                    continuation_token = result.continuation_token;
653                } else {
654                    break;
655                }
656            }
657            Err(e) => {
658                return formatter.fail(
659                    ExitCode::NetworkError,
660                    &format!("Failed to list objects: {e}"),
661                );
662            }
663        }
664    }
665
666    if error_count > 0 {
667        formatter.warning(&format!(
668            "Completed with errors: {success_count} succeeded, {error_count} failed"
669        ));
670        ExitCode::GeneralError
671    } else if success_count == 0 {
672        formatter.warning("No objects found to download.");
673        ExitCode::Success
674    } else {
675        if !formatter.is_json() {
676            formatter.success(&format!("Downloaded {success_count} file(s)."));
677        }
678        ExitCode::Success
679    }
680}
681
682async fn copy_s3_to_s3(
683    src: &RemotePath,
684    dst: &RemotePath,
685    args: &CpArgs,
686    formatter: &Formatter,
687) -> ExitCode {
688    let target = ParsedPath::Remote(dst.clone());
689    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
690        Ok(encryption) => encryption,
691        Err(error) => {
692            return formatter.fail(ExitCode::UsageError, &error);
693        }
694    };
695
696    // For S3-to-S3, we need to handle same or different aliases
697    let alias_manager = match AliasManager::new() {
698        Ok(am) => am,
699        Err(e) => {
700            formatter.error(&format!("Failed to load aliases: {e}"));
701            return ExitCode::GeneralError;
702        }
703    };
704
705    // For now, only support same-alias copies (server-side copy)
706    if src.alias != dst.alias {
707        return formatter.fail_with_suggestion(
708            ExitCode::UnsupportedFeature,
709            "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
710            "Copy via a local path or split the operation into download and upload steps.",
711        );
712    }
713
714    let alias = match alias_manager.get(&src.alias) {
715        Ok(a) => a,
716        Err(_) => {
717            return formatter.fail_with_suggestion(
718                ExitCode::NotFound,
719                &format!("Alias '{}' not found", src.alias),
720                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
721            );
722        }
723    };
724
725    let client = match S3Client::new(alias).await {
726        Ok(c) => c,
727        Err(e) => {
728            return formatter.fail(
729                ExitCode::NetworkError,
730                &format!("Failed to create S3 client: {e}"),
731            );
732        }
733    };
734
735    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
736    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
737
738    if args.dry_run {
739        let styled_src = formatter.style_file(&src_display);
740        let styled_dst = formatter.style_file(&dst_display);
741        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
742        return ExitCode::Success;
743    }
744
745    match client.copy_object(src, dst, encryption.as_ref()).await {
746        Ok(info) => {
747            if formatter.is_json() {
748                let output = CpOutput {
749                    status: "success",
750                    source: src_display,
751                    target: dst_display,
752                    size_bytes: info.size_bytes,
753                    size_human: info.size_human,
754                };
755                formatter.json(&output);
756            } else {
757                let styled_src = formatter.style_file(&src_display);
758                let styled_dst = formatter.style_file(&dst_display);
759                let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
760                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
761            }
762            ExitCode::Success
763        }
764        Err(e) => {
765            let err_str = e.to_string();
766            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
767                formatter.fail_with_suggestion(
768                    ExitCode::NotFound,
769                    &format!("Source not found: {src_display}"),
770                    "Check the source bucket and object key, then retry the copy command.",
771                )
772            } else {
773                formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
774            }
775        }
776    }
777}
778
779fn parse_kms_target(value: &str) -> Result<(String, String), String> {
780    let (target, key_id) = value
781        .split_once('=')
782        .ok_or_else(|| "Expected TARGET=KMS_KEY_ID for --enc-kms".to_string())?;
783
784    if target.is_empty() || key_id.is_empty() {
785        return Err("Expected TARGET=KMS_KEY_ID for --enc-kms".to_string());
786    }
787
788    Ok((target.to_string(), key_id.to_string()))
789}
790
791pub(crate) fn parse_destination_encryption(
792    enc_s3: &[String],
793    enc_kms: &[String],
794    target: &ParsedPath,
795) -> Result<Option<ObjectEncryptionRequest>, String> {
796    if enc_s3.is_empty() && enc_kms.is_empty() {
797        return Ok(None);
798    }
799
800    let remote = match target {
801        ParsedPath::Remote(remote) => remote,
802        ParsedPath::Local(_) => {
803            return Err("Destination encryption flags must reference a remote destination".into());
804        }
805    };
806
807    let target_display = remote.to_string();
808    let s3_matches = enc_s3.iter().any(|value| value == &target_display);
809    let kms_targets = enc_kms
810        .iter()
811        .map(|value| parse_kms_target(value))
812        .collect::<Result<Vec<_>, _>>()?;
813    let kms_match = kms_targets
814        .iter()
815        .find(|(candidate, _)| candidate == &target_display);
816
817    if !enc_s3.is_empty() && !s3_matches {
818        return Err(format!(
819            "--enc-s3 target must exactly match the remote destination: {target_display}"
820        ));
821    }
822
823    if !enc_kms.is_empty() && kms_match.is_none() {
824        return Err(format!(
825            "--enc-kms target must exactly match the remote destination: {target_display}"
826        ));
827    }
828
829    match (s3_matches, kms_match) {
830        (true, Some(_)) => Err(format!(
831            "--enc-s3 and --enc-kms cannot target the same destination: {target_display}"
832        )),
833        (true, None) => Ok(Some(ObjectEncryptionRequest::SseS3)),
834        (false, Some((_, key_id))) => Ok(Some(ObjectEncryptionRequest::SseKms {
835            key_id: key_id.clone(),
836        })),
837        (false, None) => Ok(None),
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use rc_core::{Alias, ConfigManager};
845    use tempfile::TempDir;
846
847    fn temp_alias_manager() -> (AliasManager, TempDir) {
848        let temp_dir = TempDir::new().expect("create temp dir");
849        let config_path = temp_dir.path().join("config.toml");
850        let config_manager = ConfigManager::with_path(config_path);
851        let alias_manager = AliasManager::with_config_manager(config_manager);
852        (alias_manager, temp_dir)
853    }
854
855    #[test]
856    fn test_parse_local_path() {
857        let result = parse_path("./file.txt").unwrap();
858        assert!(matches!(result, ParsedPath::Local(_)));
859    }
860
861    #[test]
862    fn test_parse_remote_path() {
863        let result = parse_path("myalias/bucket/file.txt").unwrap();
864        assert!(matches!(result, ParsedPath::Remote(_)));
865    }
866
867    #[test]
868    fn test_parse_local_absolute_path() {
869        // Use platform-appropriate absolute path
870        #[cfg(unix)]
871        let path = "/home/user/file.txt";
872        #[cfg(windows)]
873        let path = "C:\\Users\\user\\file.txt";
874
875        let result = parse_path(path).unwrap();
876        assert!(matches!(result, ParsedPath::Local(_)));
877        if let ParsedPath::Local(p) = result {
878            assert!(p.is_absolute());
879        }
880    }
881
882    #[test]
883    fn test_parse_local_relative_path() {
884        let result = parse_path("../file.txt").unwrap();
885        assert!(matches!(result, ParsedPath::Local(_)));
886    }
887
888    #[test]
889    fn test_parse_remote_path_bucket_only() {
890        let result = parse_path("myalias/bucket/").unwrap();
891        assert!(matches!(result, ParsedPath::Remote(_)));
892        if let ParsedPath::Remote(r) = result {
893            assert_eq!(r.alias, "myalias");
894            assert_eq!(r.bucket, "bucket");
895            assert!(r.key.is_empty());
896        }
897    }
898
899    #[test]
900    fn test_parse_remote_path_with_deep_key() {
901        let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
902        assert!(matches!(result, ParsedPath::Remote(_)));
903        if let ParsedPath::Remote(r) = result {
904            assert_eq!(r.alias, "myalias");
905            assert_eq!(r.bucket, "bucket");
906            assert_eq!(r.key, "dir1/dir2/file.txt");
907        }
908    }
909
910    #[test]
911    fn test_download_progress_created_for_large_transfer() {
912        let output_config = OutputConfig::default();
913        let mut progress = None;
914
915        update_download_progress(
916            &mut progress,
917            &output_config,
918            1024,
919            Some(DOWNLOAD_PROGRESS_THRESHOLD),
920        );
921
922        let progress = progress.expect("large download should create progress bar");
923        assert!(progress.is_visible());
924        progress.finish_and_clear();
925    }
926
927    #[test]
928    fn test_download_progress_skips_small_transfer() {
929        let output_config = OutputConfig::default();
930        let mut progress = None;
931
932        update_download_progress(
933            &mut progress,
934            &output_config,
935            1024,
936            Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
937        );
938
939        assert!(progress.is_none());
940    }
941
942    #[test]
943    fn test_download_progress_skips_unknown_total_size() {
944        let output_config = OutputConfig::default();
945        let mut progress = None;
946
947        update_download_progress(&mut progress, &output_config, 1024, None);
948
949        assert!(progress.is_none());
950    }
951
952    #[test]
953    fn test_download_progress_respects_no_progress_config() {
954        let output_config = OutputConfig {
955            no_progress: true,
956            ..Default::default()
957        };
958        let mut progress = None;
959
960        update_download_progress(
961            &mut progress,
962            &output_config,
963            1024,
964            Some(DOWNLOAD_PROGRESS_THRESHOLD),
965        );
966
967        let progress = progress.expect("large download should create progress state");
968        assert!(!progress.is_visible());
969    }
970
971    #[test]
972    fn test_select_upload_content_type_uses_guess_for_small_files() {
973        let selected =
974            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
975
976        assert_eq!(selected, Some("text/plain"));
977    }
978
979    #[test]
980    fn test_select_upload_content_type_skips_guess_for_multipart_files() {
981        let selected =
982            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
983
984        assert_eq!(selected, None);
985    }
986
987    #[test]
988    fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
989        let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
990
991        assert_eq!(selected, Some("text/plain"));
992    }
993
994    #[test]
995    fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
996        let selected = select_upload_content_type(
997            Some("application/octet-stream"),
998            Some("text/plain"),
999            MULTIPART_THRESHOLD + 1,
1000        );
1001
1002        assert_eq!(selected, Some("application/octet-stream"));
1003    }
1004
1005    #[test]
1006    fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
1007        let (alias_manager, temp_dir) = temp_alias_manager();
1008        let full = temp_dir.path().join("issue-2094-local").join("file.txt");
1009        let full_str = full.to_string_lossy().to_string();
1010
1011        if let Some(parent) = full.parent() {
1012            std::fs::create_dir_all(parent).expect("create parent dirs");
1013        }
1014        std::fs::write(&full, b"test").expect("write local file");
1015
1016        let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
1017        assert!(matches!(parsed, ParsedPath::Local(_)));
1018    }
1019
1020    #[test]
1021    fn test_parse_cp_path_keeps_remote_when_alias_exists() {
1022        let (alias_manager, _temp_dir) = temp_alias_manager();
1023        alias_manager
1024            .set(Alias::new("target", "http://localhost:9000", "a", "b"))
1025            .expect("set alias");
1026
1027        let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
1028            .expect("parse remote path");
1029        assert!(matches!(parsed, ParsedPath::Remote(_)));
1030    }
1031
1032    #[test]
1033    fn test_parse_cp_path_keeps_remote_when_local_missing() {
1034        let (alias_manager, _temp_dir) = temp_alias_manager();
1035        let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
1036            .expect("parse remote path");
1037        assert!(matches!(parsed, ParsedPath::Remote(_)));
1038    }
1039
1040    #[test]
1041    fn test_cp_args_defaults() {
1042        let args = CpArgs {
1043            source: "src".to_string(),
1044            target: "dst".to_string(),
1045            recursive: false,
1046            preserve: false,
1047            continue_on_error: false,
1048            overwrite: true,
1049            dry_run: false,
1050            storage_class: None,
1051            content_type: None,
1052            enc_s3: Vec::new(),
1053            enc_kms: Vec::new(),
1054        };
1055        assert!(args.overwrite);
1056        assert!(!args.recursive);
1057        assert!(!args.dry_run);
1058    }
1059
1060    #[test]
1061    fn parse_enc_kms_target_requires_equals_separator() {
1062        let error = parse_kms_target("local/bucket/file.txt").expect_err("missing key separator");
1063        assert!(error.contains("Expected TARGET=KMS_KEY_ID"));
1064    }
1065
1066    #[test]
1067    fn destination_encryption_rejects_local_targets() {
1068        let error = parse_destination_encryption(
1069            &[String::from("./local.txt")],
1070            &[],
1071            &ParsedPath::Local(std::path::PathBuf::from("./local.txt")),
1072        )
1073        .expect_err("local target should be rejected");
1074
1075        assert!(error.contains("must reference a remote destination"));
1076    }
1077
1078    #[test]
1079    fn destination_encryption_detects_conflicting_flags_for_same_target() {
1080        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1081        let error = parse_destination_encryption(
1082            &[String::from("local/bucket/file.txt")],
1083            &[String::from("local/bucket/file.txt=kms-key")],
1084            &target,
1085        )
1086        .expect_err("same target conflict should fail");
1087
1088        assert!(error.contains("cannot target the same destination"));
1089    }
1090
1091    #[test]
1092    fn destination_encryption_rejects_unmatched_s3_target() {
1093        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1094        let error =
1095            parse_destination_encryption(&[String::from("local/bucket/typo.txt")], &[], &target)
1096                .expect_err("unmatched s3 target should fail");
1097
1098        assert!(error.contains("must exactly match the remote destination"));
1099    }
1100
1101    #[test]
1102    fn destination_encryption_rejects_unmatched_kms_target() {
1103        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1104        let error = parse_destination_encryption(
1105            &[],
1106            &[String::from("local/bucket/typo.txt=kms-key")],
1107            &target,
1108        )
1109        .expect_err("unmatched kms target should fail");
1110
1111        assert!(error.contains("must exactly match the remote destination"));
1112    }
1113
1114    #[test]
1115    fn test_cp_output_serialization() {
1116        let output = CpOutput {
1117            status: "success",
1118            source: "src/file.txt".to_string(),
1119            target: "dst/file.txt".to_string(),
1120            size_bytes: Some(1024),
1121            size_human: Some("1 KiB".to_string()),
1122        };
1123        let json = serde_json::to_string(&output).unwrap();
1124        assert!(json.contains("\"status\":\"success\""));
1125        assert!(json.contains("\"size_bytes\":1024"));
1126    }
1127
1128    #[test]
1129    fn test_cp_output_skips_none_fields() {
1130        let output = CpOutput {
1131            status: "success",
1132            source: "src".to_string(),
1133            target: "dst".to_string(),
1134            size_bytes: None,
1135            size_human: None,
1136        };
1137        let json = serde_json::to_string(&output).unwrap();
1138        assert!(!json.contains("size_bytes"));
1139        assert!(!json.contains("size_human"));
1140    }
1141}