1use 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#[derive(Args, Debug)]
27#[command(after_help = CP_AFTER_HELP)]
28pub struct CpArgs {
29 pub source: String,
31
32 pub target: String,
34
35 #[arg(short, long)]
37 pub recursive: bool,
38
39 #[arg(short, long)]
41 pub preserve: bool,
42
43 #[arg(long)]
45 pub continue_on_error: bool,
46
47 #[arg(long, default_value = "true")]
49 pub overwrite: bool,
50
51 #[arg(long)]
53 pub dry_run: bool,
54
55 #[arg(long)]
57 pub storage_class: Option<String>,
58
59 #[arg(long)]
61 pub content_type: Option<String>,
62
63 #[arg(long = "enc-s3")]
65 pub enc_s3: Vec<String>,
66
67 #[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
83pub 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 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 match (&source, &target) {
127 (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
128 copy_local_to_s3(src, dst, &args, &formatter).await
130 }
131 (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
132 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 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 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 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 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 upload_file(&client, src, dst, args, formatter, encryption.as_ref()).await
238 } else {
239 upload_directory(&client, src, dst, args, formatter, encryption.as_ref()).await
241 }
242}
243
244const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
246const 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 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
301 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 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 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 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 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 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 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 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 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
508
509 if is_prefix || args.recursive {
510 download_prefix(&client, src, dst, args, formatter).await
512 } else {
513 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 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 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 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 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 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 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 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 #[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}