pars_core/operation/
copy_or_rename.rs

1use std::io::{BufRead, Read, Write};
2use std::path::{Path, PathBuf};
3use std::{fs, path};
4
5use anyhow::Result;
6use log::debug;
7use secrecy::ExposeSecret;
8
9use crate::pgp::PGPClient;
10use crate::util::fs_util::{
11    better_rename, copy_dir_recursive, get_dir_gpg_id_content, path_attack_check, path_to_str,
12};
13use crate::{IOErr, IOErrType};
14
15// Currently, we do not support cross repo rename/copy
16fn handle_overwrite_delete<I, O, E>(
17    path_to_overwrite: &Path,
18    force: bool,
19    stdin: &mut I,
20    stdout: &mut O,
21    _stderr: &mut E,
22) -> Result<bool>
23where
24    I: Read + BufRead,
25    O: Write,
26    E: Write,
27{
28    if !force {
29        stdout.write_fmt(format_args!(
30            "File {} already exists, overwrite? [y/N]: ",
31            path_to_overwrite.to_string_lossy()
32        ))?;
33        stdout.flush()?;
34        let mut input = String::new();
35        stdin.read_line(&mut input)?;
36        if !input.trim().to_lowercase().starts_with('y') {
37            stdout.write_all("Canceled\n".as_bytes())?;
38            return Ok(false);
39        }
40    }
41    if path_to_overwrite.is_file() {
42        fs::remove_file(path_to_overwrite)?;
43    } else if path_to_overwrite.is_dir() {
44        fs::remove_dir_all(path_to_overwrite)?;
45    }
46    Ok(true)
47}
48
49/// Copy or rename a file or directory, ask for confirmation if the target already exists(unless force)
50/// # Arguments
51/// * `copy` - Whether to copy or rename
52/// * `from` - The path of the file or directory to copy or rename
53/// * `to` - The path to copy or rename to
54/// * `extension` - The extension to append to the file name if the target is a file
55/// * `force` - Whether to overwrite the target if it already exists
56/// * `in_s` - The input stream
57/// * `out_s` - The output stream
58/// * `err_s` - The error stream
59fn copy_rename_file<I, O, E>(
60    copy: bool,
61    from: &Path,
62    to: &Path,
63    extension: &str,
64    force: bool,
65    in_s: &mut I,
66    out_s: &mut O,
67    err_s: &mut E,
68) -> Result<()>
69where
70    I: Read + BufRead,
71    O: Write,
72    E: Write,
73{
74    let file_name =
75        from.file_name().ok_or_else(|| IOErr::new(IOErrType::CannotGetFileName, from))?;
76
77    // assume to is a directory
78    if to.exists() {
79        return if to.is_dir() {
80            let sub_file = to.join(file_name);
81            if sub_file.exists() && !handle_overwrite_delete(&sub_file, force, in_s, out_s, err_s)?
82            {
83                return Ok(());
84            }
85            if copy {
86                fs::copy(from, sub_file)?;
87            } else {
88                better_rename(from.with_extension(extension), sub_file)?;
89            }
90            Ok(())
91        } else {
92            Err(IOErr::new(IOErrType::PathNotExist, to).into())
93        };
94    }
95
96    // assume to is a file, append extension to it
97    let to = PathBuf::from(format!("{}.{}", path_to_str(to)?, extension));
98    if to.exists() {
99        if to.is_file() {
100            if !handle_overwrite_delete(&to, force, in_s, out_s, err_s)? {
101                return Ok(());
102            }
103        } else {
104            return Err(IOErr::new(IOErrType::PathNotExist, &to).into());
105        }
106    }
107    if copy {
108        fs::copy(from, to)?;
109    } else {
110        better_rename(from.with_extension(extension), to)?;
111    }
112    Ok(())
113}
114
115/// Copy or rename a directory
116/// # Arguments
117/// * `copy` - Whether to copy or rename
118/// * `from` - The path of the directory to copy or rename
119/// * `to` - The path to copy or rename to
120/// * `force` - Whether to overwrite the target if it already exists
121/// * `in_s` - The input stream
122/// * `out_s` - The output stream
123/// * `err_s` - The error stream
124fn copy_rename_dir<I, O, E>(
125    copy: bool,
126    from: &Path,
127    to: &Path,
128    force: bool,
129    in_s: &mut I,
130    out_s: &mut O,
131    err_s: &mut E,
132) -> Result<()>
133where
134    I: Read + BufRead,
135    O: Write,
136    E: Write,
137{
138    let file_name =
139        from.file_name().ok_or_else(|| IOErr::new(IOErrType::CannotGetFileName, from))?;
140
141    if to.exists() {
142        if to.is_dir() {
143            let sub_dir = to.join(file_name);
144            if sub_dir.exists() && !handle_overwrite_delete(&sub_dir, force, in_s, out_s, err_s)? {
145                return Ok(());
146            }
147            if copy {
148                copy_dir_recursive(from, sub_dir)?;
149            } else {
150                better_rename(from, sub_dir)?;
151            }
152        } else if to.is_file() {
153            if !handle_overwrite_delete(to, force, in_s, out_s, err_s)? {
154                return Ok(());
155            }
156            if copy {
157                copy_dir_recursive(from, to)?;
158            } else {
159                better_rename(from, to)?;
160            }
161        } else {
162            return Err(IOErr::new(IOErrType::InvalidFileType, to).into());
163        }
164    } else if copy {
165        copy_dir_recursive(from, to)?;
166    } else {
167        better_rename(from, to)?;
168    }
169    Ok(())
170}
171
172pub fn copy_rename_io<I, O, E>(
173    copy: bool,
174    root: &Path,
175    from: &str,
176    to: &str,
177    file_extension: &str,
178    force: bool,
179    stdin: &mut I,
180    stdout: &mut O,
181    stderr: &mut E,
182) -> Result<()>
183where
184    I: Read + BufRead,
185    O: Write,
186    E: Write,
187{
188    let mut from_path = root.join(from);
189    let to_path = root.join(to);
190    path_attack_check(root, &from_path)?;
191    path_attack_check(root, &to_path)?;
192
193    if !from_path.exists() {
194        let try_path = PathBuf::from(format!("{}.{}", path_to_str(&from_path)?, file_extension));
195        if !try_path.exists() {
196            return Err(IOErr::new(IOErrType::PathNotExist, &from_path).into());
197        }
198        from_path = try_path;
199    }
200    debug!("copy_rename_io: from_path: {}, to_path: {}", from_path.display(), to_path.display());
201
202    let to_is_dir = to.ends_with(path::MAIN_SEPARATOR);
203    if to_is_dir && (!to_path.exists() || !to_path.is_dir()) {
204        writeln!(
205            stderr,
206            "Cannot {} '{}' to '{}': No such directory",
207            if copy { "copy" } else { "rename" },
208            from,
209            to
210        )?;
211        return Err(IOErr::new(IOErrType::PathNotExist, &to_path).into());
212    }
213
214    // Check if we're dealing with GPG-encrypted files and need to re-encrypt
215    let needs_reencryption =
216        if from_path.is_file() && from_path.extension().is_some_and(|ext| ext == file_extension) {
217            let from_dir = from_path.parent().unwrap_or(root);
218            let to_dir = if to_path.exists() && to_path.is_dir() {
219                &to_path
220            } else {
221                to_path.parent().unwrap_or(root)
222            };
223
224            // Compare GPG keys between source and destination directories
225            match (get_dir_gpg_id_content(root, from_dir), get_dir_gpg_id_content(root, to_dir)) {
226                (Ok(from_keys), Ok(to_keys)) => {
227                    let mut from_keys_sorted = from_keys.clone();
228                    let mut to_keys_sorted = to_keys.clone();
229                    from_keys_sorted.sort();
230                    to_keys_sorted.sort();
231
232                    // If keys are different, we need to re-encrypt
233                    from_keys_sorted != to_keys_sorted
234                }
235                _ => false, // If we can't get keys, default to not re-encrypting
236            }
237        } else {
238            false
239        };
240
241    if needs_reencryption {
242        debug!("Different GPG IDs detected, re-encryption required");
243
244        // If it's a file that needs re-encryption
245        if from_path.is_file() {
246            // Get the target directory for determining GPG keys
247            let target_dir = if to_path.exists() && to_path.is_dir() {
248                to_path.clone()
249            } else {
250                to_path.parent().unwrap_or(root).to_path_buf()
251            };
252
253            // Get target filename
254            let target_file = if to_path.exists() && to_path.is_dir() {
255                let filename = from_path
256                    .file_name()
257                    .ok_or_else(|| IOErr::new(IOErrType::CannotGetFileName, &from_path))?;
258                to_path.join(filename)
259            } else {
260                PathBuf::from(format!("{}.{}", path_to_str(&to_path)?, file_extension))
261            };
262
263            // Check for overwrite
264            if target_file.exists()
265                && !handle_overwrite_delete(&target_file, force, stdin, stdout, stderr)?
266            {
267                return Ok(());
268            }
269
270            // Set up clients for decryption and re-encryption
271            let from_dir = from_path.parent().unwrap_or(root);
272            let from_keys = get_dir_gpg_id_content(root, from_dir)?;
273            let to_keys = get_dir_gpg_id_content(root, &target_dir)?;
274
275            // Create client for decryption with source keys
276            let source_client = match PGPClient::new("gpg", &from_keys) {
277                Ok(client) => client,
278                Err(e) => {
279                    writeln!(stderr, "Error creating PGP client for decryption: {}", e)?;
280                    return Err(e);
281                }
282            };
283
284            // Create client for encryption with destination keys
285            let target_client = match PGPClient::new("gpg", &to_keys) {
286                Ok(client) => client,
287                Err(e) => {
288                    writeln!(stderr, "Error creating PGP client for encryption: {}", e)?;
289                    return Err(e);
290                }
291            };
292
293            // Decrypt the file
294            let content = match source_client.decrypt_stdin(root, path_to_str(&from_path)?) {
295                Ok(content) => content,
296                Err(e) => {
297                    writeln!(stderr, "Error decrypting file: {}", e)?;
298                    return Err(e);
299                }
300            };
301
302            // Encrypt with the destination keys
303            match target_client.encrypt(content.expose_secret(), path_to_str(&target_file)?) {
304                Ok(_) => {
305                    if !copy {
306                        // If this was a move operation, delete the original file
307                        if let Err(e) = fs::remove_file(&from_path) {
308                            writeln!(
309                                stderr,
310                                "Warning: Failed to delete original file after move: {}",
311                                e
312                            )?;
313                        }
314                    }
315                    return Ok(());
316                }
317                Err(e) => {
318                    writeln!(stderr, "Error re-encrypting file: {}", e)?;
319                    return Err(e);
320                }
321            }
322        }
323    }
324
325    // Default behavior for cases not needing re-encryption
326    if from_path.is_file() {
327        copy_rename_file(copy, &from_path, &to_path, file_extension, force, stdin, stdout, stderr)
328    } else if from_path.is_dir() {
329        copy_rename_dir(copy, &from_path, &to_path, force, stdin, stdout, stderr)
330    } else {
331        Err(IOErr::new(IOErrType::InvalidFileType, &from_path).into())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use std::io::{self, BufReader};
338    use std::thread::{self, sleep};
339
340    use os_pipe::pipe;
341    use pretty_assertions::assert_eq;
342    use serial_test::serial;
343
344    use super::*;
345    use crate::pgp::key_management::key_gen_batch;
346    use crate::util::defer::cleanup;
347    use crate::util::test_util::{
348        clean_up_test_key, create_dir_structure, gen_unique_temp_dir, get_test_email,
349        get_test_executable, gpg_key_edit_example_batch, gpg_key_gen_example_batch, write_gpg_id,
350    };
351
352    #[test]
353    #[serial]
354    #[ignore = "need run interactively"]
355    fn rename_test() {
356        // Original structure:
357        // root
358        // ├── a.gpg
359        // ├── d_dir/
360        // ├── e_dir/
361        // └── b.gpg
362        let (_tmp_dir, root) = gen_unique_temp_dir();
363        let structure: &[(Option<&str>, &[&str])] =
364            &[(None, &["a.gpg", "b.gpg"][..]), (Some("d_dir"), &[][..]), (Some("e_dir"), &[][..])];
365        create_dir_structure(&root, structure);
366
367        cleanup!(
368            {
369                let (stdin, mut stdin_w) = pipe().unwrap();
370                let mut stdin = BufReader::new(stdin);
371                let mut stdout = io::stdout().lock();
372                let mut stderr = io::stderr().lock();
373
374                // Rename a.gpg to c.gpg
375                copy_rename_io(
376                    false,
377                    &root,
378                    "a",
379                    "c",
380                    "gpg",
381                    false,
382                    &mut stdin,
383                    &mut stdout,
384                    &mut stderr,
385                )
386                .unwrap();
387                assert_eq!(false, root.join("a.gpg").exists());
388                assert_eq!(true, root.join("c.gpg").exists());
389
390                // Rename b.gpg to c.gpg, without force, input "n" interactively
391                thread::spawn(move || {
392                    sleep(std::time::Duration::from_millis(100));
393                    stdin_w.write_all(b"n\n").unwrap();
394                });
395                copy_rename_io(
396                    false,
397                    &root,
398                    "b",
399                    "c",
400                    "gpg",
401                    false,
402                    &mut stdin,
403                    &mut stdout,
404                    &mut stderr,
405                )
406                .unwrap();
407                assert_eq!(true, root.join("b.gpg").exists());
408
409                // Rename b.gpg to c.gpg, with force
410                copy_rename_io(
411                    false,
412                    &root,
413                    "b",
414                    "c",
415                    "gpg",
416                    true,
417                    &mut stdin,
418                    &mut stdout,
419                    &mut stderr,
420                )
421                .unwrap();
422                assert_eq!(false, root.join("b.gpg").exists());
423                assert_eq!(true, root.join("c.gpg").exists());
424
425                // Now, try to rename file into a dir(end with path separator)
426                copy_rename_io(
427                    false,
428                    &root,
429                    "c",
430                    &format!("d_dir{}", path::MAIN_SEPARATOR_STR),
431                    "gpg",
432                    false,
433                    &mut stdin,
434                    &mut stdout,
435                    &mut stderr,
436                )
437                .unwrap();
438                assert_eq!(false, root.join("c.gpg").exists());
439                assert_eq!(true, root.join("d_dir").join("c.gpg").exists());
440
441                // Try to rename d_dir to e_dir, should be e_dir/d_dir
442                copy_rename_io(
443                    false,
444                    &root,
445                    "d_dir",
446                    "e_dir",
447                    "gpg",
448                    false,
449                    &mut stdin,
450                    &mut stdout,
451                    &mut stderr,
452                )
453                .unwrap();
454                assert_eq!(false, root.join("d_dir").exists());
455                assert_eq!(true, root.join("e_dir").join("d_dir").exists());
456            },
457            {}
458        );
459    }
460
461    #[test]
462    #[serial]
463    #[ignore = "need run interactively"]
464    fn copy_test() {
465        // Original structure:
466        // root
467        // ├── a.gpg
468        // ├── d_dir/
469        // ├── e_dir/
470        // └── b.gpg
471        let (_tmp_dir, root) = gen_unique_temp_dir();
472        let structure: &[(Option<&str>, &[&str])] =
473            &[(None, &["a.gpg", "b.gpg"][..]), (Some("d_dir"), &[][..]), (Some("e_dir"), &[][..])];
474        create_dir_structure(&root, structure);
475
476        cleanup!(
477            {
478                let (stdin, mut stdin_w) = pipe().unwrap();
479                let mut stdin = BufReader::new(stdin);
480                let mut stdout = io::stdout().lock();
481                let mut stderr = io::stderr().lock();
482
483                // Copy a.gpg to c.gpg
484                fs::write(root.join("a.gpg"), "foo_a").unwrap();
485                assert_eq!(false, root.join("c.gpg").exists());
486                copy_rename_io(
487                    true,
488                    &root,
489                    "a",
490                    "c",
491                    "gpg",
492                    false,
493                    &mut stdin,
494                    &mut stdout,
495                    &mut stderr,
496                )
497                .unwrap();
498                assert_eq!(true, root.join("a.gpg").exists());
499                assert_eq!(true, root.join("c.gpg").exists());
500                assert_eq!("foo_a", fs::read_to_string(root.join("c.gpg")).unwrap());
501
502                // Copy b.gpg to c.gpg, without force, input "n" interactively
503                fs::write(root.join("b.gpg"), "foo_b").unwrap();
504                thread::spawn(move || {
505                    sleep(std::time::Duration::from_millis(100));
506                    stdin_w.write_all(b"n\n").unwrap();
507                });
508
509                copy_rename_io(
510                    true,
511                    &root,
512                    "b",
513                    "c",
514                    "gpg",
515                    false,
516                    &mut stdin,
517                    &mut stdout,
518                    &mut stderr,
519                )
520                .unwrap();
521                assert_ne!("foo_b", fs::read_to_string(root.join("c.gpg")).unwrap());
522
523                // Copy b.gpg to c.gpg, with force, overwrite the content of c.gpg
524                copy_rename_io(
525                    true,
526                    &root,
527                    "b",
528                    "c",
529                    "gpg",
530                    true,
531                    &mut stdin,
532                    &mut stdout,
533                    &mut stderr,
534                )
535                .unwrap();
536                assert_eq!("foo_b", fs::read_to_string(root.join("c.gpg")).unwrap());
537
538                // Now, try to copy file into a dir(end with path separator)
539                fs::write(root.join("c.gpg"), "foo_c").unwrap();
540                copy_rename_io(
541                    true,
542                    &root,
543                    "c",
544                    &format!("d_dir{}", path::MAIN_SEPARATOR_STR),
545                    "gpg",
546                    false,
547                    &mut stdin,
548                    &mut stdout,
549                    &mut stderr,
550                )
551                .unwrap();
552                assert_eq!(true, root.join("c.gpg").exists());
553                assert_eq!("foo_c", fs::read_to_string(root.join("c.gpg")).unwrap());
554
555                // Try to copy d_dir to e_dir, should be e_dir/d_dir
556                copy_rename_io(
557                    true,
558                    &root,
559                    "d_dir",
560                    "e_dir",
561                    "gpg",
562                    false,
563                    &mut stdin,
564                    &mut stdout,
565                    &mut stderr,
566                )
567                .unwrap();
568                assert_eq!(true, root.join("d_dir").exists());
569                assert_eq!(true, root.join("e_dir").join("d_dir").exists());
570            },
571            {}
572        );
573    }
574
575    #[test]
576    #[serial]
577    #[ignore = "need run interactively"]
578    // Try to access parent directory, should be blocked
579    fn path_attack_protection_test() {
580        // Simple structure:
581        // root
582        // └── a.gpg
583        let (_tmp_dir, root) = gen_unique_temp_dir();
584        let structure: &[(Option<&str>, &[&str])] = &[(None, &["a.gpg"][..])];
585        create_dir_structure(&root, structure);
586
587        cleanup!(
588            {
589                let mut stdin = io::stdin().lock();
590                let mut stdout = io::stdout().lock();
591                let mut stderr = io::stderr().lock();
592                if copy_rename_io(
593                    false,
594                    &root,
595                    "../../a",
596                    "c",
597                    "gpg",
598                    true,
599                    &mut stdin,
600                    &mut stdout,
601                    &mut stderr,
602                )
603                .is_ok()
604                {
605                    panic!(
606                        "Should not be able to access parent directory: {}/../../a",
607                        root.display()
608                    );
609                }
610
611                if copy_rename_io(
612                    true,
613                    &root,
614                    "a",
615                    "../../c",
616                    "gpg",
617                    true,
618                    &mut stdin,
619                    &mut stdout,
620                    &mut stderr,
621                )
622                .is_ok()
623                {
624                    panic!(
625                        "Should not be able to access parent directory: {}/../../c",
626                        root.display()
627                    );
628                }
629            },
630            {}
631        );
632    }
633
634    #[test]
635    #[serial]
636    #[ignore = "need run interactively"]
637    fn re_encrypt_case_test() {
638        // Set up directory structure:
639        // root
640        // ├── .gpg-id (with key1)
641        // ├── file1.gpg (encrypted with key1)
642        // └── subdir/
643        //     └── .gpg-id (with key2)
644        let executable = get_test_executable();
645        let (_tmp_dir, root) = gen_unique_temp_dir();
646        let structure: &[(Option<&str>, &[&str])] = &[(None, &[][..]), (Some("subdir"), &[][..])];
647        create_dir_structure(&root, structure);
648
649        // Create first client with first key
650        key_gen_batch(&executable, &gpg_key_gen_example_batch()).unwrap();
651        let email1 = get_test_email();
652        let client1 = PGPClient::new(executable.clone(), &[&email1]).unwrap();
653        client1.key_edit_batch(&gpg_key_edit_example_batch()).unwrap();
654        let key1_fpr = client1.get_keys_fpr();
655
656        // Create a second key for the subdirectory
657        // We create a new email by appending a suffix to make it unique
658        let email2 = format!("sub-{}", email1);
659        let second_key_batch = format!(
660            r#"%echo Generating a second key
661Key-Type: RSA
662Key-Length: 2048
663Subkey-Type: RSA
664Subkey-Length: 2048
665Name-Real: Test User Sub
666Name-Email: {}
667Expire-Date: 0
668Passphrase: password
669%commit
670%echo Key generation complete
671"#,
672            email2
673        );
674
675        key_gen_batch(&executable, &second_key_batch).unwrap();
676        let client2 = PGPClient::new(executable.clone(), &[&email2]).unwrap();
677        client2.key_edit_batch(&gpg_key_edit_example_batch()).unwrap();
678        let key2_fpr = client2.get_keys_fpr();
679
680        // Write different .gpg-id files to root and subdirectory
681        write_gpg_id(&root, &key1_fpr);
682        write_gpg_id(&root.join("subdir"), &key2_fpr);
683
684        // Set up standard I/O for the copy operation
685        let (stdin, _stdin_w) = pipe().unwrap();
686        let mut stdin = BufReader::new(stdin);
687        let mut stdout = io::stdout().lock();
688        let mut stderr = io::stderr().lock();
689
690        cleanup!(
691            {
692                // Create a test file in the root directory encrypted with key1
693                let test_content = "This is a secret message";
694                client1.encrypt(test_content, root.join("file1.gpg").to_str().unwrap()).unwrap();
695
696                // Copy the file to the subdirectory
697                copy_rename_io(
698                    true,
699                    &root,
700                    "file1",
701                    "subdir/file1",
702                    "gpg",
703                    false,
704                    &mut stdin,
705                    &mut stdout,
706                    &mut stderr,
707                )
708                .unwrap();
709
710                // Verify both files exist (since it's a copy)
711                assert!(root.join("file1.gpg").exists());
712                assert!(root.join("subdir").join("file1.gpg").exists());
713
714                // Verify the file in the subdirectory can be decrypted with client2
715                let decrypted = client2.decrypt_stdin(&root, "subdir/file1.gpg").unwrap();
716                assert_eq!(decrypted.expose_secret(), test_content);
717
718                // Now test moving a file (which should also trigger re-encryption)
719                // Create another file in the root
720                let test_content2 = "Another secret message for moving";
721                client1.encrypt(test_content2, root.join("file2.gpg").to_str().unwrap()).unwrap();
722
723                // Move the file to the subdirectory
724                copy_rename_io(
725                    false, // false = move instead of copy
726                    &root,
727                    "file2",
728                    "subdir/file2",
729                    "gpg",
730                    false,
731                    &mut stdin,
732                    &mut stdout,
733                    &mut stderr,
734                )
735                .unwrap();
736
737                // Verify the original file is gone (since it's a move)
738                assert!(!root.join("file2.gpg").exists());
739                assert!(root.join("subdir").join("file2.gpg").exists());
740
741                // Verify the moved file can be decrypted with client2
742                let decrypted = client2.decrypt_stdin(&root, "subdir/file2.gpg").unwrap();
743                assert_eq!(decrypted.expose_secret(), test_content2);
744            },
745            {
746                // Clean up both keys
747                let emails = vec![email1.as_str(), email2.as_str()];
748                clean_up_test_key(&executable, &emails).unwrap();
749            }
750        );
751    }
752}