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
87    if args.preserve {
88        return formatter.fail(
89            ExitCode::UnsupportedFeature,
90            "--preserve is not implemented; refusing to continue with a silently ignored option",
91        );
92    }
93    if args.storage_class.is_some() {
94        return formatter.fail(
95            ExitCode::UnsupportedFeature,
96            "--storage-class is not implemented; refusing to continue with a silently ignored option",
97        );
98    }
99
100    let alias_manager = AliasManager::new().ok();
101
102    // Parse source and target paths
103    let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
104        Ok(p) => p,
105        Err(e) => {
106            return formatter.fail_with_suggestion(
107                ExitCode::UsageError,
108                &format!("Invalid source path: {e}"),
109                REMOTE_PATH_SUGGESTION,
110            );
111        }
112    };
113
114    let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
115        Ok(p) => p,
116        Err(e) => {
117            return formatter.fail_with_suggestion(
118                ExitCode::UsageError,
119                &format!("Invalid target path: {e}"),
120                REMOTE_PATH_SUGGESTION,
121            );
122        }
123    };
124
125    // Determine copy direction
126    match (&source, &target) {
127        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
128            // Local to S3
129            copy_local_to_s3(src, dst, &args, &formatter).await
130        }
131        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
132            // S3 to Local
133            copy_s3_to_local(src, dst, &args, &formatter).await
134        }
135        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
136            if args.recursive {
137                return formatter.fail(
138                    ExitCode::UnsupportedFeature,
139                    "Recursive S3-to-S3 copy is not implemented",
140                );
141            }
142            // S3 to S3
143            copy_s3_to_s3(src, dst, &args, &formatter).await
144        }
145        (ParsedPath::Local(_), ParsedPath::Local(_)) => formatter.fail_with_suggestion(
146            ExitCode::UsageError,
147            "Cannot copy between two local paths. Use system cp command.",
148            "Use your local shell cp command when both paths are on the filesystem.",
149        ),
150    }
151}
152
153fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
154    let parsed = parse_path(path)?;
155
156    let ParsedPath::Remote(remote) = &parsed else {
157        return Ok(parsed);
158    };
159
160    if let Some(manager) = alias_manager
161        && matches!(manager.exists(&remote.alias), Ok(true))
162    {
163        return Ok(parsed);
164    }
165
166    if Path::new(path).exists() {
167        return Ok(ParsedPath::Local(PathBuf::from(path)));
168    }
169
170    Ok(parsed)
171}
172
173async fn copy_local_to_s3(
174    src: &Path,
175    dst: &RemotePath,
176    args: &CpArgs,
177    formatter: &Formatter,
178) -> ExitCode {
179    let target = ParsedPath::Remote(dst.clone());
180    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
181        Ok(encryption) => encryption,
182        Err(error) => {
183            return formatter.fail(ExitCode::UsageError, &error);
184        }
185    };
186
187    // Check if source exists
188    if !src.exists() {
189        return formatter.fail_with_suggestion(
190            ExitCode::NotFound,
191            &format!("Source not found: {}", src.display()),
192            "Check the local source path and retry the copy command.",
193        );
194    }
195
196    // If source is a directory, require recursive flag
197    if src.is_dir() && !args.recursive {
198        return formatter.fail_with_suggestion(
199            ExitCode::UsageError,
200            "Source is a directory. Use -r/--recursive to copy directories.",
201            "Retry with -r or --recursive to copy a directory tree.",
202        );
203    }
204
205    // Load alias and create client
206    let alias_manager = match AliasManager::new() {
207        Ok(am) => am,
208        Err(e) => {
209            formatter.error(&format!("Failed to load aliases: {e}"));
210            return ExitCode::GeneralError;
211        }
212    };
213
214    let alias = match alias_manager.get(&dst.alias) {
215        Ok(a) => a,
216        Err(_) => {
217            return formatter.fail_with_suggestion(
218                ExitCode::NotFound,
219                &format!("Alias '{}' not found", dst.alias),
220                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
221            );
222        }
223    };
224
225    let client = match S3Client::new(alias).await {
226        Ok(c) => c,
227        Err(e) => {
228            return formatter.fail(
229                ExitCode::NetworkError,
230                &format!("Failed to create S3 client: {e}"),
231            );
232        }
233    };
234
235    if src.is_file() {
236        // Single file upload
237        upload_file(&client, src, dst, args, formatter, encryption.as_ref()).await
238    } else {
239        // Directory upload
240        upload_directory(&client, src, dst, args, formatter, encryption.as_ref()).await
241    }
242}
243
244/// Multipart upload threshold: files larger than this size use multipart upload.
245const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
246/// Download progress threshold: avoid flicker for tiny downloads while surfacing meaningful waits.
247const DOWNLOAD_PROGRESS_THRESHOLD: u64 = 4 * 1024 * 1024;
248
249fn update_download_progress(
250    progress: &mut Option<ProgressBar>,
251    output_config: &OutputConfig,
252    bytes_downloaded: u64,
253    total_size: Option<u64>,
254) {
255    let Some(total_size) = total_size else {
256        return;
257    };
258
259    if total_size < DOWNLOAD_PROGRESS_THRESHOLD {
260        return;
261    }
262
263    let progress_bar =
264        progress.get_or_insert_with(|| ProgressBar::new(output_config.clone(), total_size));
265    progress_bar.set_position(bytes_downloaded);
266}
267
268fn print_upload_success(
269    formatter: &Formatter,
270    info: &rc_core::ObjectInfo,
271    src_display: &str,
272    dst_display: &str,
273) {
274    if formatter.is_json() {
275        let output = CpOutput {
276            status: "success",
277            source: src_display.to_string(),
278            target: dst_display.to_string(),
279            size_bytes: info.size_bytes,
280            size_human: info.size_human.clone(),
281        };
282        formatter.json(&output);
283    } else {
284        let styled_src = formatter.style_file(src_display);
285        let styled_dst = formatter.style_file(dst_display);
286        let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
287        formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
288    }
289}
290
291async fn upload_file(
292    client: &S3Client,
293    src: &Path,
294    dst: &RemotePath,
295    args: &CpArgs,
296    formatter: &Formatter,
297    encryption: Option<&ObjectEncryptionRequest>,
298) -> ExitCode {
299    // Determine destination key
300    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
301        // If destination is a directory, use source filename
302        let filename = src.file_name().unwrap_or_default().to_string_lossy();
303        format!("{}{}", dst.key, filename)
304    } else {
305        dst.key.clone()
306    };
307
308    let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
309    let src_display = src.display().to_string();
310    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
311
312    if args.dry_run {
313        let styled_src = formatter.style_file(&src_display);
314        let styled_dst = formatter.style_file(&dst_display);
315        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
316        return ExitCode::Success;
317    }
318
319    // Get file size for progress bar decision
320    let file_size = match std::fs::metadata(src) {
321        Ok(m) => m.len(),
322        Err(e) => {
323            return formatter.fail(
324                ExitCode::GeneralError,
325                &format!("Failed to read {src_display}: {e}"),
326            );
327        }
328    };
329
330    // Determine content type
331    let guessed_type: Option<String> = mime_guess::from_path(src)
332        .first()
333        .map(|m| m.essence_str().to_string());
334    let content_type = select_upload_content_type(
335        args.content_type.as_deref(),
336        guessed_type.as_deref(),
337        file_size,
338    );
339
340    // Show progress bar for large files
341    let progress = if file_size > MULTIPART_THRESHOLD {
342        tracing::debug!(
343            file_size,
344            threshold = MULTIPART_THRESHOLD,
345            "Using multipart upload for large file"
346        );
347        Some(ProgressBar::new(formatter.output_config(), file_size))
348    } else {
349        tracing::debug!(file_size, "Using single put_object for small file");
350        None
351    };
352
353    // Upload
354    match client
355        .put_object_from_path(&target, src, content_type, encryption, |bytes_sent| {
356            if let Some(ref pb) = progress {
357                pb.set_position(bytes_sent);
358            }
359        })
360        .await
361    {
362        Ok(info) => {
363            if let Some(ref pb) = progress {
364                pb.finish_and_clear();
365            }
366            print_upload_success(formatter, &info, &src_display, &dst_display);
367            ExitCode::Success
368        }
369        Err(e) => {
370            if let Some(ref pb) = progress {
371                pb.finish_and_clear();
372            }
373            formatter.fail(
374                ExitCode::NetworkError,
375                &format!("Failed to upload {src_display}: {e}"),
376            )
377        }
378    }
379}
380
381fn select_upload_content_type<'a>(
382    explicit_type: Option<&'a str>,
383    guessed_type: Option<&'a str>,
384    file_size: u64,
385) -> Option<&'a str> {
386    if file_size > MULTIPART_THRESHOLD {
387        explicit_type
388    } else {
389        explicit_type.or(guessed_type)
390    }
391}
392
393async fn upload_directory(
394    client: &S3Client,
395    src: &Path,
396    dst: &RemotePath,
397    args: &CpArgs,
398    formatter: &Formatter,
399    encryption: Option<&ObjectEncryptionRequest>,
400) -> ExitCode {
401    use std::fs;
402
403    let mut success_count = 0;
404    let mut error_count = 0;
405
406    // Walk directory
407    fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
408        let mut files = Vec::new();
409        for entry in fs::read_dir(dir)? {
410            let entry = entry?;
411            let path = entry.path();
412            if path.is_file() {
413                let relative = path.strip_prefix(base).unwrap_or(&path);
414                let relative_str = relative.to_string_lossy().to_string();
415                files.push((path, relative_str));
416            } else if path.is_dir() {
417                files.extend(walk_dir(&path, base)?);
418            }
419        }
420        Ok(files)
421    }
422
423    let files = match walk_dir(src, src) {
424        Ok(f) => f,
425        Err(e) => {
426            return formatter.fail(
427                ExitCode::GeneralError,
428                &format!("Failed to read directory: {e}"),
429            );
430        }
431    };
432
433    for (file_path, relative_path) in files {
434        // Build destination key
435        let dst_key = if dst.key.is_empty() {
436            relative_path.replace('\\', "/")
437        } else if dst.key.ends_with('/') {
438            format!("{}{}", dst.key, relative_path.replace('\\', "/"))
439        } else {
440            format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
441        };
442
443        let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
444
445        let result = upload_file(client, &file_path, &target, args, formatter, encryption).await;
446
447        if result == ExitCode::Success {
448            success_count += 1;
449        } else {
450            error_count += 1;
451            if !args.continue_on_error {
452                return result;
453            }
454        }
455    }
456
457    if error_count > 0 {
458        formatter.warning(&format!(
459            "Completed with errors: {success_count} succeeded, {error_count} failed"
460        ));
461        ExitCode::GeneralError
462    } else {
463        if !formatter.is_json() {
464            formatter.success(&format!("Uploaded {success_count} file(s)."));
465        }
466        ExitCode::Success
467    }
468}
469
470async fn copy_s3_to_local(
471    src: &RemotePath,
472    dst: &Path,
473    args: &CpArgs,
474    formatter: &Formatter,
475) -> ExitCode {
476    // Load alias and create client
477    let alias_manager = match AliasManager::new() {
478        Ok(am) => am,
479        Err(e) => {
480            formatter.error(&format!("Failed to load aliases: {e}"));
481            return ExitCode::GeneralError;
482        }
483    };
484
485    let alias = match alias_manager.get(&src.alias) {
486        Ok(a) => a,
487        Err(_) => {
488            return formatter.fail_with_suggestion(
489                ExitCode::NotFound,
490                &format!("Alias '{}' not found", src.alias),
491                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
492            );
493        }
494    };
495
496    let client = match S3Client::new(alias).await {
497        Ok(c) => c,
498        Err(e) => {
499            return formatter.fail(
500                ExitCode::NetworkError,
501                &format!("Failed to create S3 client: {e}"),
502            );
503        }
504    };
505
506    // Check if source is a prefix (directory-like)
507    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
508
509    if is_prefix || args.recursive {
510        // Download multiple objects
511        download_prefix(&client, src, dst, args, formatter).await
512    } else {
513        // Download single object
514        download_file(&client, src, dst, args, formatter).await
515    }
516}
517
518pub(super) async fn download_file(
519    client: &S3Client,
520    src: &RemotePath,
521    dst: &Path,
522    args: &CpArgs,
523    formatter: &Formatter,
524) -> ExitCode {
525    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
526
527    // Determine destination path
528    let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
529        let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
530        let filename = match safe_download_relative_path(filename, "") {
531            Ok(filename) => filename,
532            Err(error) => {
533                return formatter.fail(
534                    ExitCode::UsageError,
535                    &format!("Unsafe object key '{}': {error}", src.key),
536                );
537            }
538        };
539        dst.join(filename)
540    } else {
541        dst.to_path_buf()
542    };
543
544    let dst_display = dst_path.display().to_string();
545
546    if args.dry_run {
547        let styled_src = formatter.style_file(&src_display);
548        let styled_dst = formatter.style_file(&dst_display);
549        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
550        return ExitCode::Success;
551    }
552
553    // Check if destination exists
554    if dst_path.exists() && !args.overwrite {
555        return formatter.fail_with_suggestion(
556            ExitCode::Conflict,
557            &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
558            "Retry with --overwrite if replacing the destination file is intended.",
559        );
560    }
561
562    // Create parent directories
563    if let Some(parent) = dst_path.parent()
564        && !parent.exists()
565        && let Err(e) = std::fs::create_dir_all(parent)
566    {
567        return formatter.fail(
568            ExitCode::GeneralError,
569            &format!("Failed to create directory: {e}"),
570        );
571    }
572
573    let output_config = formatter.output_config();
574    let mut progress = None;
575
576    // Download object
577    let result = client
578        .download_object_to_path(src, &dst_path, |bytes_downloaded, total_size| {
579            update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
580        })
581        .await;
582
583    if let Some(ref pb) = progress {
584        pb.finish_and_clear();
585    }
586
587    match result {
588        Ok(size) => {
589            let size = size as i64;
590
591            if formatter.is_json() {
592                let output = CpOutput {
593                    status: "success",
594                    source: src_display,
595                    target: dst_display,
596                    size_bytes: Some(size),
597                    size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
598                };
599                formatter.json(&output);
600            } else {
601                let styled_src = formatter.style_file(&src_display);
602                let styled_dst = formatter.style_file(&dst_display);
603                let styled_size =
604                    formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
605                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
606            }
607            ExitCode::Success
608        }
609        Err(e) => {
610            let err_str = e.to_string();
611            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
612                formatter.fail_with_suggestion(
613                    ExitCode::NotFound,
614                    &format!("Object not found: {src_display}"),
615                    "Check the object key and bucket path, then retry the copy command.",
616                )
617            } else {
618                formatter.fail(
619                    ExitCode::NetworkError,
620                    &format!("Failed to download {src_display}: {e}"),
621                )
622            }
623        }
624    }
625}
626
627async fn download_prefix(
628    client: &S3Client,
629    src: &RemotePath,
630    dst: &Path,
631    args: &CpArgs,
632    formatter: &Formatter,
633) -> ExitCode {
634    use rc_core::ListOptions;
635
636    let mut success_count = 0;
637    let mut error_count = 0;
638    let mut continuation_token: Option<String> = None;
639
640    loop {
641        let options = ListOptions {
642            recursive: true,
643            max_keys: Some(1000),
644            continuation_token: continuation_token.clone(),
645            ..Default::default()
646        };
647
648        match client.list_objects(src, options).await {
649            Ok(result) => {
650                for item in result.items {
651                    if item.is_dir {
652                        continue;
653                    }
654
655                    // Calculate relative path from prefix
656                    let relative_path = match safe_download_relative_path(&item.key, &src.key) {
657                        Ok(path) => path,
658                        Err(error) => {
659                            error_count += 1;
660                            formatter.error(&format!(
661                                "Refusing unsafe object key '{}': {error}",
662                                item.key
663                            ));
664                            if !args.continue_on_error {
665                                return ExitCode::UsageError;
666                            }
667                            continue;
668                        }
669                    };
670                    let dst_path = match safe_download_destination(dst, &relative_path).await {
671                        Ok(path) => path,
672                        Err(error) => {
673                            error_count += 1;
674                            formatter.error(&format!(
675                                "Refusing unsafe destination for '{}': {error}",
676                                item.key
677                            ));
678                            if !args.continue_on_error {
679                                return ExitCode::UsageError;
680                            }
681                            continue;
682                        }
683                    };
684
685                    let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
686                    let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
687
688                    if result == ExitCode::Success {
689                        success_count += 1;
690                    } else {
691                        error_count += 1;
692                        if !args.continue_on_error {
693                            return result;
694                        }
695                    }
696                }
697
698                if result.truncated {
699                    continuation_token = result.continuation_token;
700                } else {
701                    break;
702                }
703            }
704            Err(e) => {
705                return formatter.fail(
706                    ExitCode::NetworkError,
707                    &format!("Failed to list objects: {e}"),
708                );
709            }
710        }
711    }
712
713    if error_count > 0 {
714        formatter.warning(&format!(
715            "Completed with errors: {success_count} succeeded, {error_count} failed"
716        ));
717        ExitCode::GeneralError
718    } else if success_count == 0 {
719        formatter.warning("No objects found to download.");
720        ExitCode::Success
721    } else {
722        if !formatter.is_json() {
723            formatter.success(&format!("Downloaded {success_count} file(s)."));
724        }
725        ExitCode::Success
726    }
727}
728
729pub(super) fn safe_download_relative_path(key: &str, prefix: &str) -> Result<PathBuf, String> {
730    let relative = key
731        .strip_prefix(prefix)
732        .ok_or_else(|| format!("key is outside requested prefix '{prefix}'"))?
733        .trim_start_matches('/');
734
735    let mut path = PathBuf::new();
736    for component in relative.split(['/', '\\']) {
737        if component.is_empty() {
738            continue;
739        }
740        if matches!(component, "." | "..") {
741            return Err("path traversal components are not allowed".to_string());
742        }
743        if component.contains(':') {
744            return Err("colon characters are not allowed in download paths".to_string());
745        }
746        if component.ends_with(['.', ' ']) {
747            return Err("download path components must not end in a dot or space".to_string());
748        }
749        let stem = component.split('.').next().unwrap_or_default();
750        let stem = stem.to_ascii_uppercase();
751        if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
752            || (stem.len() == 4
753                && (stem.starts_with("COM") || stem.starts_with("LPT"))
754                && matches!(stem.as_bytes()[3], b'1'..=b'9'))
755        {
756            return Err("reserved Windows device names are not allowed".to_string());
757        }
758        path.push(component);
759    }
760
761    if path.as_os_str().is_empty() {
762        return Err("object key does not contain a file path".to_string());
763    }
764
765    Ok(path)
766}
767
768pub(super) async fn safe_download_destination(
769    root: &Path,
770    relative: &Path,
771) -> Result<PathBuf, String> {
772    let mut destination = root.to_path_buf();
773    for component in relative.components() {
774        let std::path::Component::Normal(component) = component else {
775            return Err("destination path contains a non-normal component".to_string());
776        };
777        destination.push(component);
778        match tokio::fs::symlink_metadata(&destination).await {
779            Ok(metadata) if metadata.file_type().is_symlink() => {
780                return Err(format!(
781                    "destination component '{}' is a symbolic link",
782                    destination.display()
783                ));
784            }
785            Ok(_) => {}
786            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
787            Err(error) => {
788                return Err(format!(
789                    "failed to inspect destination '{}': {error}",
790                    destination.display()
791                ));
792            }
793        }
794    }
795
796    Ok(destination)
797}
798
799async fn copy_s3_to_s3(
800    src: &RemotePath,
801    dst: &RemotePath,
802    args: &CpArgs,
803    formatter: &Formatter,
804) -> ExitCode {
805    let target = ParsedPath::Remote(dst.clone());
806    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
807        Ok(encryption) => encryption,
808        Err(error) => {
809            return formatter.fail(ExitCode::UsageError, &error);
810        }
811    };
812
813    // For S3-to-S3, we need to handle same or different aliases
814    let alias_manager = match AliasManager::new() {
815        Ok(am) => am,
816        Err(e) => {
817            formatter.error(&format!("Failed to load aliases: {e}"));
818            return ExitCode::GeneralError;
819        }
820    };
821
822    // For now, only support same-alias copies (server-side copy)
823    if src.alias != dst.alias {
824        return formatter.fail_with_suggestion(
825            ExitCode::UnsupportedFeature,
826            "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
827            "Copy via a local path or split the operation into download and upload steps.",
828        );
829    }
830
831    let alias = match alias_manager.get(&src.alias) {
832        Ok(a) => a,
833        Err(_) => {
834            return formatter.fail_with_suggestion(
835                ExitCode::NotFound,
836                &format!("Alias '{}' not found", src.alias),
837                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
838            );
839        }
840    };
841
842    let client = match S3Client::new(alias).await {
843        Ok(c) => c,
844        Err(e) => {
845            return formatter.fail(
846                ExitCode::NetworkError,
847                &format!("Failed to create S3 client: {e}"),
848            );
849        }
850    };
851
852    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
853    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
854
855    if args.dry_run {
856        let styled_src = formatter.style_file(&src_display);
857        let styled_dst = formatter.style_file(&dst_display);
858        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
859        return ExitCode::Success;
860    }
861
862    match client.head_object(src).await {
863        Ok(info)
864            if info
865                .size_bytes
866                .is_some_and(|size| size > 5 * 1024 * 1024 * 1024) =>
867        {
868            return formatter.fail(
869                ExitCode::UnsupportedFeature,
870                "Objects larger than 5 GiB require multipart copy, which is not implemented",
871            );
872        }
873        Ok(_) => {}
874        Err(rc_core::Error::NotFound(_)) => {
875            return formatter.fail_with_suggestion(
876                ExitCode::NotFound,
877                &format!("Source not found: {src_display}"),
878                "Check the source bucket and object key, then retry the copy command.",
879            );
880        }
881        Err(error) => {
882            return formatter.fail(
883                ExitCode::NetworkError,
884                &format!("Failed to inspect source object: {error}"),
885            );
886        }
887    }
888
889    match client.copy_object(src, dst, encryption.as_ref()).await {
890        Ok(info) => {
891            if formatter.is_json() {
892                let output = CpOutput {
893                    status: "success",
894                    source: src_display,
895                    target: dst_display,
896                    size_bytes: info.size_bytes,
897                    size_human: info.size_human,
898                };
899                formatter.json(&output);
900            } else {
901                let styled_src = formatter.style_file(&src_display);
902                let styled_dst = formatter.style_file(&dst_display);
903                let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
904                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
905            }
906            ExitCode::Success
907        }
908        Err(e) => {
909            let err_str = e.to_string();
910            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
911                formatter.fail_with_suggestion(
912                    ExitCode::NotFound,
913                    &format!("Source not found: {src_display}"),
914                    "Check the source bucket and object key, then retry the copy command.",
915                )
916            } else {
917                formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
918            }
919        }
920    }
921}
922
923fn parse_kms_target(value: &str) -> Result<(String, String), String> {
924    let (target, key_id) = value
925        .split_once('=')
926        .ok_or_else(|| "Expected TARGET=KMS_KEY_ID for --enc-kms".to_string())?;
927
928    if target.is_empty() || key_id.is_empty() {
929        return Err("Expected TARGET=KMS_KEY_ID for --enc-kms".to_string());
930    }
931
932    Ok((target.to_string(), key_id.to_string()))
933}
934
935pub(crate) fn parse_destination_encryption(
936    enc_s3: &[String],
937    enc_kms: &[String],
938    target: &ParsedPath,
939) -> Result<Option<ObjectEncryptionRequest>, String> {
940    if enc_s3.is_empty() && enc_kms.is_empty() {
941        return Ok(None);
942    }
943
944    let remote = match target {
945        ParsedPath::Remote(remote) => remote,
946        ParsedPath::Local(_) => {
947            return Err("Destination encryption flags must reference a remote destination".into());
948        }
949    };
950
951    let target_display = remote.to_string();
952    let s3_matches = enc_s3.iter().any(|value| value == &target_display);
953    let kms_targets = enc_kms
954        .iter()
955        .map(|value| parse_kms_target(value))
956        .collect::<Result<Vec<_>, _>>()?;
957    let kms_match = kms_targets
958        .iter()
959        .find(|(candidate, _)| candidate == &target_display);
960
961    if !enc_s3.is_empty() && !s3_matches {
962        return Err(format!(
963            "--enc-s3 target must exactly match the remote destination: {target_display}"
964        ));
965    }
966
967    if !enc_kms.is_empty() && kms_match.is_none() {
968        return Err(format!(
969            "--enc-kms target must exactly match the remote destination: {target_display}"
970        ));
971    }
972
973    match (s3_matches, kms_match) {
974        (true, Some(_)) => Err(format!(
975            "--enc-s3 and --enc-kms cannot target the same destination: {target_display}"
976        )),
977        (true, None) => Ok(Some(ObjectEncryptionRequest::SseS3)),
978        (false, Some((_, key_id))) => Ok(Some(ObjectEncryptionRequest::SseKms {
979            key_id: key_id.clone(),
980        })),
981        (false, None) => Ok(None),
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use rc_core::{Alias, ConfigManager};
989    use tempfile::TempDir;
990
991    fn temp_alias_manager() -> (AliasManager, TempDir) {
992        let temp_dir = TempDir::new().expect("create temp dir");
993        let config_path = temp_dir.path().join("config.toml");
994        let config_manager = ConfigManager::with_path(config_path);
995        let alias_manager = AliasManager::with_config_manager(config_manager);
996        (alias_manager, temp_dir)
997    }
998
999    #[test]
1000    fn test_parse_local_path() {
1001        let result = parse_path("./file.txt").unwrap();
1002        assert!(matches!(result, ParsedPath::Local(_)));
1003    }
1004
1005    #[test]
1006    fn test_parse_remote_path() {
1007        let result = parse_path("myalias/bucket/file.txt").unwrap();
1008        assert!(matches!(result, ParsedPath::Remote(_)));
1009    }
1010
1011    #[test]
1012    fn test_parse_local_absolute_path() {
1013        // Use platform-appropriate absolute path
1014        #[cfg(unix)]
1015        let path = "/home/user/file.txt";
1016        #[cfg(windows)]
1017        let path = "C:\\Users\\user\\file.txt";
1018
1019        let result = parse_path(path).unwrap();
1020        assert!(matches!(result, ParsedPath::Local(_)));
1021        if let ParsedPath::Local(p) = result {
1022            assert!(p.is_absolute());
1023        }
1024    }
1025
1026    #[test]
1027    fn test_parse_local_relative_path() {
1028        let result = parse_path("../file.txt").unwrap();
1029        assert!(matches!(result, ParsedPath::Local(_)));
1030    }
1031
1032    #[test]
1033    fn test_parse_remote_path_bucket_only() {
1034        let result = parse_path("myalias/bucket/").unwrap();
1035        assert!(matches!(result, ParsedPath::Remote(_)));
1036        if let ParsedPath::Remote(r) = result {
1037            assert_eq!(r.alias, "myalias");
1038            assert_eq!(r.bucket, "bucket");
1039            assert!(r.key.is_empty());
1040        }
1041    }
1042
1043    #[test]
1044    fn test_parse_remote_path_with_deep_key() {
1045        let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
1046        assert!(matches!(result, ParsedPath::Remote(_)));
1047        if let ParsedPath::Remote(r) = result {
1048            assert_eq!(r.alias, "myalias");
1049            assert_eq!(r.bucket, "bucket");
1050            assert_eq!(r.key, "dir1/dir2/file.txt");
1051        }
1052    }
1053
1054    #[test]
1055    fn test_download_progress_created_for_large_transfer() {
1056        let output_config = OutputConfig::default();
1057        let mut progress = None;
1058
1059        update_download_progress(
1060            &mut progress,
1061            &output_config,
1062            1024,
1063            Some(DOWNLOAD_PROGRESS_THRESHOLD),
1064        );
1065
1066        let progress = progress.expect("large download should create progress bar");
1067        assert!(progress.is_visible());
1068        progress.finish_and_clear();
1069    }
1070
1071    #[test]
1072    fn test_download_progress_skips_small_transfer() {
1073        let output_config = OutputConfig::default();
1074        let mut progress = None;
1075
1076        update_download_progress(
1077            &mut progress,
1078            &output_config,
1079            1024,
1080            Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
1081        );
1082
1083        assert!(progress.is_none());
1084    }
1085
1086    #[test]
1087    fn test_download_progress_skips_unknown_total_size() {
1088        let output_config = OutputConfig::default();
1089        let mut progress = None;
1090
1091        update_download_progress(&mut progress, &output_config, 1024, None);
1092
1093        assert!(progress.is_none());
1094    }
1095
1096    #[test]
1097    fn test_download_progress_respects_no_progress_config() {
1098        let output_config = OutputConfig {
1099            no_progress: true,
1100            ..Default::default()
1101        };
1102        let mut progress = None;
1103
1104        update_download_progress(
1105            &mut progress,
1106            &output_config,
1107            1024,
1108            Some(DOWNLOAD_PROGRESS_THRESHOLD),
1109        );
1110
1111        let progress = progress.expect("large download should create progress state");
1112        assert!(!progress.is_visible());
1113    }
1114
1115    #[test]
1116    fn download_relative_path_preserves_safe_nested_keys() {
1117        let relative = safe_download_relative_path("reports/2026/july/data.csv", "reports/")
1118            .expect("safe key should resolve");
1119
1120        assert_eq!(
1121            relative,
1122            PathBuf::from("2026").join("july").join("data.csv")
1123        );
1124    }
1125
1126    #[test]
1127    fn download_relative_path_rejects_traversal_and_absolute_keys() {
1128        for key in [
1129            "reports/../../escaped",
1130            "reports/..\\..\\escaped",
1131            "reports/C:/escaped",
1132            "reports/safe:stream",
1133            "reports/CON.txt",
1134            "reports/trailing.",
1135            "/absolute/path",
1136        ] {
1137            assert!(
1138                safe_download_relative_path(key, "reports/").is_err(),
1139                "unsafe key should be rejected: {key}"
1140            );
1141        }
1142    }
1143
1144    #[cfg(unix)]
1145    #[tokio::test]
1146    async fn download_destination_rejects_existing_symlink_components() {
1147        use std::os::unix::fs::symlink;
1148
1149        let root = tempfile::tempdir().expect("create destination root");
1150        let outside = tempfile::tempdir().expect("create outside directory");
1151        symlink(outside.path(), root.path().join("linked")).expect("create test symlink");
1152
1153        let result =
1154            safe_download_destination(root.path(), &PathBuf::from("linked/file.txt")).await;
1155
1156        assert!(result.is_err());
1157    }
1158
1159    #[cfg(unix)]
1160    #[tokio::test]
1161    async fn download_destination_rejects_existing_symlink_file() {
1162        use std::os::unix::fs::symlink;
1163
1164        let root = tempfile::tempdir().expect("create destination root");
1165        let outside = tempfile::tempdir().expect("create outside directory");
1166        let outside_file = outside.path().join("file.txt");
1167        std::fs::write(&outside_file, b"outside").expect("write outside file");
1168        symlink(&outside_file, root.path().join("file.txt")).expect("create test symlink");
1169
1170        let result = safe_download_destination(root.path(), &PathBuf::from("file.txt")).await;
1171
1172        assert!(result.is_err());
1173    }
1174
1175    #[test]
1176    fn test_select_upload_content_type_uses_guess_for_small_files() {
1177        let selected =
1178            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
1179
1180        assert_eq!(selected, Some("text/plain"));
1181    }
1182
1183    #[test]
1184    fn test_select_upload_content_type_skips_guess_for_multipart_files() {
1185        let selected =
1186            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
1187
1188        assert_eq!(selected, None);
1189    }
1190
1191    #[test]
1192    fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
1193        let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
1194
1195        assert_eq!(selected, Some("text/plain"));
1196    }
1197
1198    #[test]
1199    fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
1200        let selected = select_upload_content_type(
1201            Some("application/octet-stream"),
1202            Some("text/plain"),
1203            MULTIPART_THRESHOLD + 1,
1204        );
1205
1206        assert_eq!(selected, Some("application/octet-stream"));
1207    }
1208
1209    #[test]
1210    fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
1211        let (alias_manager, temp_dir) = temp_alias_manager();
1212        let full = temp_dir.path().join("issue-2094-local").join("file.txt");
1213        let full_str = full.to_string_lossy().to_string();
1214
1215        if let Some(parent) = full.parent() {
1216            std::fs::create_dir_all(parent).expect("create parent dirs");
1217        }
1218        std::fs::write(&full, b"test").expect("write local file");
1219
1220        let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
1221        assert!(matches!(parsed, ParsedPath::Local(_)));
1222    }
1223
1224    #[test]
1225    fn test_parse_cp_path_keeps_remote_when_alias_exists() {
1226        let (alias_manager, _temp_dir) = temp_alias_manager();
1227        alias_manager
1228            .set(Alias::new("target", "http://localhost:9000", "a", "b"))
1229            .expect("set alias");
1230
1231        let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
1232            .expect("parse remote path");
1233        assert!(matches!(parsed, ParsedPath::Remote(_)));
1234    }
1235
1236    #[test]
1237    fn test_parse_cp_path_keeps_remote_when_local_missing() {
1238        let (alias_manager, _temp_dir) = temp_alias_manager();
1239        let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
1240            .expect("parse remote path");
1241        assert!(matches!(parsed, ParsedPath::Remote(_)));
1242    }
1243
1244    #[test]
1245    fn test_cp_args_defaults() {
1246        let args = CpArgs {
1247            source: "src".to_string(),
1248            target: "dst".to_string(),
1249            recursive: false,
1250            preserve: false,
1251            continue_on_error: false,
1252            overwrite: true,
1253            dry_run: false,
1254            storage_class: None,
1255            content_type: None,
1256            enc_s3: Vec::new(),
1257            enc_kms: Vec::new(),
1258        };
1259        assert!(args.overwrite);
1260        assert!(!args.recursive);
1261        assert!(!args.dry_run);
1262    }
1263
1264    #[test]
1265    fn parse_enc_kms_target_requires_equals_separator() {
1266        let error = parse_kms_target("local/bucket/file.txt").expect_err("missing key separator");
1267        assert!(error.contains("Expected TARGET=KMS_KEY_ID"));
1268    }
1269
1270    #[test]
1271    fn destination_encryption_rejects_local_targets() {
1272        let error = parse_destination_encryption(
1273            &[String::from("./local.txt")],
1274            &[],
1275            &ParsedPath::Local(std::path::PathBuf::from("./local.txt")),
1276        )
1277        .expect_err("local target should be rejected");
1278
1279        assert!(error.contains("must reference a remote destination"));
1280    }
1281
1282    #[test]
1283    fn destination_encryption_detects_conflicting_flags_for_same_target() {
1284        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1285        let error = parse_destination_encryption(
1286            &[String::from("local/bucket/file.txt")],
1287            &[String::from("local/bucket/file.txt=kms-key")],
1288            &target,
1289        )
1290        .expect_err("same target conflict should fail");
1291
1292        assert!(error.contains("cannot target the same destination"));
1293    }
1294
1295    #[test]
1296    fn destination_encryption_rejects_unmatched_s3_target() {
1297        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1298        let error =
1299            parse_destination_encryption(&[String::from("local/bucket/typo.txt")], &[], &target)
1300                .expect_err("unmatched s3 target should fail");
1301
1302        assert!(error.contains("must exactly match the remote destination"));
1303    }
1304
1305    #[test]
1306    fn destination_encryption_rejects_unmatched_kms_target() {
1307        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1308        let error = parse_destination_encryption(
1309            &[],
1310            &[String::from("local/bucket/typo.txt=kms-key")],
1311            &target,
1312        )
1313        .expect_err("unmatched kms target should fail");
1314
1315        assert!(error.contains("must exactly match the remote destination"));
1316    }
1317
1318    #[test]
1319    fn test_cp_output_serialization() {
1320        let output = CpOutput {
1321            status: "success",
1322            source: "src/file.txt".to_string(),
1323            target: "dst/file.txt".to_string(),
1324            size_bytes: Some(1024),
1325            size_human: Some("1 KiB".to_string()),
1326        };
1327        let json = serde_json::to_string(&output).unwrap();
1328        assert!(json.contains("\"status\":\"success\""));
1329        assert!(json.contains("\"size_bytes\":1024"));
1330    }
1331
1332    #[test]
1333    fn test_cp_output_skips_none_fields() {
1334        let output = CpOutput {
1335            status: "success",
1336            source: "src".to_string(),
1337            target: "dst".to_string(),
1338            size_bytes: None,
1339            size_human: None,
1340        };
1341        let json = serde_json::to_string(&output).unwrap();
1342        assert!(!json.contains("size_bytes"));
1343        assert!(!json.contains("size_human"));
1344    }
1345}