• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MilesCranmer / rip2 / #805

03 Sep 2025 06:42AM UTC coverage: 68.603% (+5.6%) from 62.996%
#805

push

378 of 551 relevant lines covered (68.6%)

188.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.85
/src/lib.rs
1
use clap::CommandFactory;
2
use fs_extra::dir::get_size;
3
use std::fs::Metadata;
4
use std::io::{BufRead, BufReader, Error, ErrorKind, Write};
5
use std::path::{Path, PathBuf};
6
use std::{env, fs};
7
use walkdir::WalkDir;
8

9
/// Information needed to create a directory with specific permissions
10
#[derive(Debug, Clone)]
11
pub struct DirToCreate {
12
    pub path: PathBuf,
13
    pub permissions: Option<fs::Permissions>,
14
}
15

16
// Platform-specific imports
17
#[cfg(unix)]
18
use nix::libc;
19
#[cfg(unix)]
20
use nix::sys::stat::Mode;
21
#[cfg(unix)]
22
use nix::unistd::mkfifo;
23
#[cfg(unix)]
24
use std::os::unix::fs::{symlink, FileTypeExt, PermissionsExt};
25

26
#[cfg(target_os = "windows")]
27
use std::os::windows::fs::symlink_file as symlink;
28

29
pub mod args;
30
pub mod completions;
31
pub mod record;
32
pub mod util;
33

34
use args::Args;
35
use record::{Record, RecordItem, DEFAULT_FILE_LOCK};
36

37
const LINES_TO_INSPECT: usize = 6;
38
const FILES_TO_INSPECT: usize = 6;
39
pub const BIG_FILE_THRESHOLD: u64 = 500_000_000; // 500 MB
40

41
pub fn run(cli: &Args, mode: impl util::TestingMode, stream: &mut impl Write) -> Result<(), Error> {
74✔
42
    args::validate_args(cli)?;
149✔
43
    let graveyard: &PathBuf = &get_graveyard(cli.graveyard.clone());
73✔
44

45
    if !graveyard.exists() {
×
46
        fs::create_dir_all(graveyard)?;
37✔
47

48
        #[cfg(unix)]
49
        {
50
            fs::set_permissions(graveyard, fs::Permissions::from_mode(0o700))?;
74✔
51
        }
×
52
    }
×
53

×
54
    // Stores the deleted files
55
    let record = Record::<DEFAULT_FILE_LOCK>::new(graveyard);
56
    let cwd = &env::current_dir()?;
57

58
    // If the user wishes to restore everything
59
    if cli.decompose {
73✔
60
        // In force mode, skip the prompt to decompose
73✔
61
        if cli.force || util::prompt_yes("Really unlink the entire graveyard?", &mode, stream)? {
62
            fs::remove_dir_all(graveyard)?;
63
        }
×
64
    } else if let Some(ref mut graves_to_exhume) = cli.unbury.clone() {
65
        // Vector to hold the grave path of items we want to unbury.
13✔
66
        // This will be used to determine which items to remove from the
4✔
67
        // record following the unbury.
68
        // Initialize it with the targets passed to -r
85✔
69

70
        // If -s is also passed, push all files found by seance onto
71
        // the graves_to_exhume.
72
        if cli.seance && record.open().is_ok() {
73
            let gravepath = util::join_absolute(graveyard, dunce::canonicalize(cwd)?);
74
            for grave in record.seance(&gravepath)? {
75
                graves_to_exhume.push(grave.dest);
76
            }
10✔
77
        }
20✔
78

11✔
79
        // Otherwise, add the last deleted file
×
80
        if graves_to_exhume.is_empty() {
81
            if let Ok(s) = record.get_last_bury() {
82
                graves_to_exhume.push(s);
83
            }
84
        }
16✔
85

20✔
86
        let allow_rename = util::allow_rename();
×
87

88
        // Go through the graveyard and exhume all the graves
89
        for line in record.lines_of_graves(graves_to_exhume) {
90
            let entry = RecordItem::new(&line);
×
91
            let orig: PathBuf = if util::symlink_exists(&entry.orig) {
92
                util::rename_grave(&entry.orig)
93
            } else {
22✔
94
                PathBuf::from(&entry.orig)
66✔
95
            };
88✔
96
            let dirs_to_create = build_dirs_to_create_from_graveyard(&entry.dest, &orig);
8✔
97

98
            move_target(
18✔
99
                &entry.dest,
100
                &orig,
88✔
101
                allow_rename,
102
                &mode,
103
                stream,
22✔
104
                cli.force,
22✔
105
                &dirs_to_create,
22✔
106
            )
22✔
107
            .map_err(|e| {
22✔
108
                Error::new(
22✔
109
                    e.kind(),
22✔
110
                    format!(
111
                        "Unbury failed: couldn't copy files from {} to {}",
22✔
112
                        entry.dest.display(),
×
113
                        orig.display()
×
114
                    ),
×
115
                )
×
116
            })?;
×
117
            writeln!(
×
118
                stream,
119
                "Returned {} to {}",
120
                entry.dest.display(),
121
                orig.display()
22✔
122
            )?;
×
123
        }
124
        record.log_exhumed_graves(graves_to_exhume)?;
×
125
    } else if cli.seance {
×
126
        let gravepath = util::join_absolute(graveyard, dunce::canonicalize(cwd)?);
127
        writeln!(stream, "{: <19}\tpath", "deletion_time")?;
128
        for grave in record.seance(&gravepath)? {
16✔
129
            let formatted_time = grave.format_time_for_display()?;
53✔
130
            writeln!(stream, "{}\t{}", formatted_time, grave.dest.display())?;
20✔
131
        }
×
132
    } else if cli.targets.is_empty() {
12✔
133
        Args::command().print_help()?;
18✔
134
    } else {
×
135
        let allow_rename = util::allow_rename();
136
        for target in &cli.targets {
48✔
137
            bury_target(
2✔
138
                target,
139
                graveyard,
47✔
140
                &record,
152✔
141
                cwd,
142
                cli.inspect,
54✔
143
                allow_rename,
54✔
144
                &mode,
54✔
145
                stream,
54✔
146
                cli.force,
54✔
147
            )?;
54✔
148
        }
54✔
149
    }
54✔
150

54✔
151
    Ok(())
152
}
153

154
#[allow(clippy::too_many_arguments)]
155
fn bury_target<const FILE_LOCK: bool>(
68✔
156
    target: &PathBuf,
157
    graveyard: &PathBuf,
158
    record: &Record<FILE_LOCK>,
159
    cwd: &Path,
54✔
160
    inspect: bool,
161
    allow_rename: bool,
162
    mode: &impl util::TestingMode,
163
    stream: &mut impl Write,
164
    force: bool,
165
) -> Result<(), Error> {
166
    // Check if source exists
167
    let metadata = &fs::symlink_metadata(target).map_err(|_| {
168
        Error::new(
169
            ErrorKind::NotFound,
170
            format!(
171
                "Cannot remove {}: no such file or directory",
216✔
172
                target.to_str().unwrap()
1✔
173
            ),
1✔
174
        )
1✔
175
    })?;
1✔
176
    // Canonicalize the path unless it's a symlink
2✔
177
    let source = &if metadata.file_type().is_symlink() {
178
        cwd.join(target)
179
    } else {
180
        dunce::canonicalize(cwd.join(target))
181
            .map_err(|e| Error::new(e.kind(), "Failed to canonicalize path"))?
53✔
182
    };
×
183

184
    if inspect && !should_we_bury_this(target, source, metadata, mode, stream)? {
53✔
185
        // User chose to not bury the file
×
186
    } else if source.starts_with(
187
        dunce::canonicalize(graveyard)
188
            .map_err(|e| Error::new(e.kind(), "Failed to canonicalize graveyard path"))?,
62✔
189
    ) {
190
        // If rip is called on a file already in the graveyard, prompt
103✔
191
        // to permanently delete it instead.
×
192
        if force
×
193
            || util::prompt_yes(
194
                format!(
195
                    "{} is already in the graveyard.\nPermanently unlink it?",
196
                    source.display()
2✔
197
                ),
1✔
198
                mode,
1✔
199
                stream,
1✔
200
            )?
1✔
201
        {
202
            if fs::remove_dir_all(source).is_err() {
1✔
203
                fs::remove_file(source).map_err(|e| {
1✔
204
                    Error::new(e.kind(), format!("Couldn't unlink {}", source.display()))
205
                })?;
206
            }
2✔
207
        } else {
6✔
208
            writeln!(stream, "Skipping {}", source.display())?;
×
209
            // TODO: In the original code, this was a hard return from the entire
210
            // method (i.e., `run`). I think it should just be a return from the bury
211
            // (meaning a `continue` in the original code's loop). But I'm not sure.
212
        }
×
213
    } else {
214
        let (dest, dirs_to_create) = build_graveyard_dest(graveyard, source);
215
        let dest: &Path = &{
216
            // Resolve a name conflict if necessary
217
            if util::symlink_exists(&dest) {
218
                util::rename_grave(dest)
49✔
219
            } else {
×
220
                dest
221
            }
×
222
        };
8✔
223

224
        let moved = move_target(
45✔
225
            source,
226
            dest,
227
            allow_rename,
228
            mode,
229
            stream,
×
230
            force,
×
231
            &dirs_to_create,
×
232
        )
×
233
        .map_err(|e| {
×
234
            fs::remove_dir_all(dest).ok();
×
235
            Error::new(e.kind(), "Failed to bury file")
×
236
        })?;
237

1✔
238
        if moved {
3✔
239
            // Clean up any partial buries due to permission error
3✔
240
            record.write_log(source, dest)?;
241
        }
242
    }
×
243

244
    Ok(())
180✔
245
}
246

247
fn should_we_bury_this(
248
    target: &Path,
51✔
249
    source: &PathBuf,
250
    metadata: &Metadata,
251
    mode: &impl util::TestingMode,
11✔
252
    stream: &mut impl Write,
253
) -> Result<bool, Error> {
254
    if metadata.is_dir() {
255
        // Get the size of the directory and all its contents
256
        {
257
            let num_bytes = get_size(source).map_err(|_| {
258
                Error::other(format!(
22✔
259
                    "Failed to get size of directory: {}",
260
                    source.display()
261
                ))
20✔
262
            })?;
×
263
            writeln!(
×
264
                stream,
×
265
                "{}: directory, {} including:",
266
                target.to_str().unwrap(),
267
                util::humanize_bytes(num_bytes)
×
268
            )?;
×
269
        }
270

×
271
        // Print the first few top-level files in the directory
×
272
        for entry in WalkDir::new(source)
273
            .sort_by(|a, b| a.file_name().cmp(b.file_name()))
274
            .min_depth(1)
275
            .max_depth(1)
276
            .into_iter()
26✔
277
            .filter_map(Result::ok)
2,811✔
278
            .take(FILES_TO_INSPECT)
×
279
        {
×
280
            writeln!(stream, "{}", entry.path().display())?;
×
281
        }
×
282
    } else {
×
283
        writeln!(
284
            stream,
105✔
285
            "{}: file, {}",
286
            &target.to_str().unwrap(),
287
            util::humanize_bytes(metadata.len())
6✔
288
        )?;
×
289
        // Read the file and print the first few lines
290
        if let Ok(source_file) = fs::File::open(source) {
×
291
            for line in BufReader::new(source_file)
×
292
                .lines()
293
                .take(LINES_TO_INSPECT)
294
                .filter_map(Result::ok)
12✔
295
            {
6✔
296
                writeln!(stream, "> {line}")?;
×
297
            }
×
298
        } else {
×
299
            writeln!(stream, "Error reading {}", source.display())?;
300
        }
18✔
301
    }
302
    util::prompt_yes(
303
        format!("Send {} to the graveyard?", target.to_str().unwrap()),
×
304
        mode,
305
        stream,
306
    )
307
}
×
308

×
309
/// Plan graveyard directory structure and permissions
×
310
fn build_graveyard_dest(graveyard: &Path, source: &Path) -> (PathBuf, Vec<DirToCreate>) {
311
    let mut dest = graveyard.to_path_buf();
312
    let mut dirs_to_create = Vec::new();
313
    let mut cumulative_source = PathBuf::new();
314

98✔
315
    for component in source.components() {
294✔
316
        // Build cumulative source path
196✔
317
        cumulative_source.push(component.as_os_str());
196✔
318

319
        // Process component for destination using shared logic
710✔
320
        if util::push_component_to_dest(&mut dest, &component) {
321
            // Only add directories to the list (skip the final file component)
×
322
            if cumulative_source.is_dir() {
323
                let permissions = fs::metadata(&cumulative_source)
324
                    .map(|m| m.permissions())
×
325
                    .ok();
326
                dirs_to_create.push(DirToCreate {
416✔
327
                    path: dest.clone(),
1,002✔
328
                    permissions,
1,002✔
329
                });
330
            }
1,002✔
331
        }
668✔
332
    }
334✔
333

334
    (dest, dirs_to_create)
335
}
336

337
/// Plan source directory structure and permissions
338
fn build_dirs_to_create_from_graveyard(
98✔
339
    graveyard_path: &Path,
340
    orig_path: &Path,
341
) -> Vec<DirToCreate> {
342
    let mut dirs_to_create = Vec::new();
44✔
343

344
    // Walk from file to root and collect permissions to propagate
345
    let mut graveyard_current = graveyard_path.parent();
346
    let mut orig_current = orig_path.parent();
88✔
347

348
    while let (Some(g), Some(o)) = (graveyard_current, orig_current) {
349
        let permissions = fs::metadata(g).map(|m| m.permissions()).ok();
132✔
350
        dirs_to_create.push(DirToCreate {
132✔
351
            path: o.to_path_buf(),
352
            permissions,
634✔
353
        });
364✔
354

×
355
        // Move up one level
×
356
        graveyard_current = g.parent();
×
357
        orig_current = o.parent();
358
    }
359
    dirs_to_create.reverse();
360
    dirs_to_create
×
361
}
×
362

363
/// Create the missing directories needed for a copy operation.
44✔
364
///
44✔
365
/// Important: permissions are applied *after* the copy finishes; otherwise a non-writable parent
366
/// (e.g. mode 0555) can prevent creating deeper directories or writing the file itself.
367
fn create_dirs_for_copy(dirs_to_create: &[DirToCreate]) -> Result<Vec<DirToCreate>, Error> {
368
    let mut created = Vec::new();
100✔
369

370
    // Create directories one by one in order (parent to child).
371
    // This assumes `dirs_to_create` is ordered from root to leaf.
812✔
372
    for dir in dirs_to_create {
356✔
373
        if dir.path.exists() {
374
            continue;
250✔
375
        }
×
376

×
377
        fs::create_dir(&dir.path).map_err(|e| {
×
378
            Error::new(
379
                e.kind(),
380
                format!("Failed to create directory {}: {}", dir.path.display(), e),
381
            )
382
        })?;
500✔
383
        created.push(dir.clone());
×
384
    }
×
385

×
386
    Ok(created)
×
387
}
388

389
fn apply_dir_permissions(dirs: &[DirToCreate]) -> Result<(), Error> {
390
    for dir in dirs.iter().rev() {
391
        if let Some(perms) = &dir.permissions {
392
            fs::set_permissions(&dir.path, perms.clone()).map_err(|e| {
393
                Error::new(
100✔
394
                    e.kind(),
395
                    format!("Failed to set permissions on {}: {}", dir.path.display(), e),
396
                )
397
            })?;
398
        }
399
    }
74✔
400
    Ok(())
401
}
402

403
/// Move a target to a given destination, copying if necessary.
404
/// Returns true if the target was moved, false if it was not (due to
405
/// user input)
406
pub fn move_target(
407
    target: &Path,
408
    dest: &Path,
409
    allow_rename: bool,
410
    mode: &impl util::TestingMode,
239✔
411
    stream: &mut impl Write,
24✔
412
    force: bool,
413
    dirs_to_create: &[DirToCreate],
414
) -> Result<bool, Error> {
415
    // Try a simple rename, which will only work within the same mount point.
×
416
    // Trying to rename across filesystems will throw errno 18.
417
    if allow_rename && fs::rename(target, dest).is_ok() {
100✔
418
        return Ok(true);
36✔
419
    }
420

88✔
421
    // If that didn't work, then we need to copy and rm.
1✔
422
    let created_dirs = create_dirs_for_copy(dirs_to_create)?;
2✔
423

1✔
424
    if fs::symlink_metadata(target)?.is_dir() {
1✔
425
        let moved = move_dir(target, dest, mode, stream, force)?;
2✔
426
        apply_dir_permissions(&created_dirs)?;
2✔
427
        Ok(moved)
428
    } else {
429
        let moved = copy_file(target, dest, mode, stream, force).map_err(|e| {
430
            Error::new(
×
431
                e.kind(),
×
432
                format!(
×
433
                    "Failed to copy file from {} to {}",
×
434
                    target.display(),
435
                    dest.display()
436
                ),
43✔
437
            )
438
        })?;
439
        fs::remove_file(target).map_err(|e| {
440
            Error::new(
441
                e.kind(),
442
                format!("Failed to remove file: {}", target.display()),
7✔
443
            )
444
        })?;
445
        apply_dir_permissions(&created_dirs)?;
446
        Ok(moved)
447
    }
448
}
449

450
/// Move a target which is a directory to a given destination, copying if necessary.
863✔
451
/// Returns true *always*, as the creation of the directory is enough to mark it as successful.
452
pub fn move_dir(
1,670✔
453
    target: &Path,
454
    dest: &Path,
1,670✔
455
    mode: &impl util::TestingMode,
835✔
456
    stream: &mut impl Write,
457
    force: bool,
×
458
) -> Result<bool, Error> {
1,564✔
459
    let mut dest_dirs_and_perms: Vec<(PathBuf, fs::Permissions)> = Vec::new();
1,173✔
460

×
461
    // Walk the source, creating directories and copying files as needed
×
462
    for entry in WalkDir::new(target).into_iter().filter_map(Result::ok) {
×
463
        // Path without the top-level directory
×
464
        let orphan = entry
×
465
            .path()
×
466
            .strip_prefix(target)
467
            .map_err(|_| Error::other("Parent directory isn't a prefix of child directories?"))?;
468

469
        if entry.file_type().is_dir() {
470
            let dest_dir = dest.join(orphan);
471
            fs::create_dir_all(&dest_dir).map_err(|e| {
782✔
472
                Error::new(
×
473
                    e.kind(),
×
474
                    format!(
×
475
                        "Failed to create dir: {} in {}",
476
                        entry.path().display(),
477
                        dest_dir.display()
×
478
                    ),
×
479
                )
×
480
            })?;
×
481

×
482
            // Preserve directory permissions, but apply after traversal so we can
483
            // still create children under non-writable directories.
484
            let source_metadata = fs::metadata(entry.path()).map_err(|e| {
485
                Error::new(
444✔
486
                    e.kind(),
×
487
                    format!("Failed to get metadata for: {}", entry.path().display()),
×
488
                )
×
489
            })?;
×
490
            dest_dirs_and_perms.push((dest_dir, source_metadata.permissions()));
×
491
        } else {
×
492
            copy_file(entry.path(), &dest.join(orphan), mode, stream, force).map_err(|e| {
493
                Error::new(
494
                    e.kind(),
495
                    format!(
496
                        "Failed to copy file from {} to {}",
497
                        entry.path().display(),
8✔
498
                        dest.join(orphan).display()
1✔
499
                    ),
2✔
500
                )
4✔
501
            })?;
502
        }
503
    }
504
    fs::remove_dir_all(target).map_err(|e| {
6✔
505
        Error::new(
506
            e.kind(),
507
            format!("Failed to remove dir: {}", target.display()),
493✔
508
        )
509
    })?;
510

511
    // Apply collected perms from leaf to root to minimize traversal surprises.
512
    for (dest_dir, perms) in dest_dirs_and_perms.into_iter().rev() {
513
        fs::set_permissions(&dest_dir, perms).map_err(|e| {
514
            Error::new(
1,479✔
515
                e.kind(),
×
516
                format!("Failed to set permissions on: {}", dest_dir.display()),
517
            )
×
518
        })?;
519
    }
5✔
520

4✔
521
    Ok(true)
4✔
522
}
4✔
523

8✔
524
pub fn copy_file(
12✔
525
    source: &Path,
526
    dest: &Path,
4✔
527
    mode: &impl util::TestingMode,
4✔
528
    stream: &mut impl Write,
529
    force: bool,
530
) -> Result<bool, Error> {
3✔
531
    let metadata = fs::symlink_metadata(source)?;
532
    let filetype = metadata.file_type();
533

534
    if metadata.len() > BIG_FILE_THRESHOLD {
490✔
535
        // In force mode, we default to copying big files
1,455✔
536
        if !force
485✔
537
            && util::prompt_yes(
538
                format!(
539
                    "About to copy a big file ({} is {})\nPermanently delete this file instead?",
540
                    source.display(),
×
541
                    util::humanize_bytes(metadata.len())
3✔
542
                ),
3✔
543
                mode,
544
                stream,
3✔
545
            )?
1✔
546
        {
547
            return Ok(false);
548
        }
×
549
    }
3✔
550

×
551
    if filetype.is_file() {
1✔
552
        fs::copy(source, dest)?;
553
        return Ok(true);
554
    }
×
555

3✔
556
    #[cfg(unix)]
557
    if filetype.is_fifo() {
558
        let perm: libc::mode_t = (metadata.permissions().mode() & 0o777) as libc::mode_t;
3✔
559
        let mode = Mode::from_bits_truncate(perm);
2✔
560

2✔
561
        mkfifo(dest, mode)?;
2✔
562
        return Ok(true);
4✔
563
    }
564

2✔
565
    if filetype.is_symlink() {
2✔
566
        let target = fs::read_link(source)?;
567
        symlink(target, dest)?;
568
        return Ok(true);
2✔
569
    }
570

1✔
571
    match fs::copy(source, dest) {
572
        Err(e) => {
573
            // Special file: Try copying it as normal, but this probably won't work
×
574
            // In force mode, we don't delete special files, we error
575
            if !force
576
                && util::prompt_yes(
577
                    format!(
156✔
578
                        "Non-regular file or directory: {}\nPermanently delete the file?",
312✔
579
                        source.display()
16✔
580
                    ),
18✔
581
                    mode,
×
582
                    stream,
16✔
583
                )?
2✔
584
            {
2✔
585
                Ok(false)
586
            } else {
×
587
                Err(e)
×
588
            }
589
        }
24✔
590
        Ok(_) => Ok(true),
36✔
591
    }
592
}
593

594
pub fn get_graveyard(graveyard: Option<PathBuf>) -> PathBuf {
595
    graveyard.unwrap_or_else(|| {
596
        if let Ok(env_graveyard) = env::var("RIP_GRAVEYARD") {
597
            PathBuf::from(env_graveyard)
598
        } else if let Ok(mut env_graveyard) = env::var("XDG_DATA_HOME") {
599
            if !env_graveyard.ends_with(std::path::MAIN_SEPARATOR) {
600
                env_graveyard.push(std::path::MAIN_SEPARATOR);
601
            }
602
            env_graveyard.push_str("graveyard");
1✔
603
            PathBuf::from(env_graveyard)
604
        } else {
605
            let user = util::get_user();
606
            env::temp_dir().join(format!("graveyard-{user}"))
607
        }
608
    })
6✔
609
}
610

611
/// Testing module for exposing internal functions to unit tests.
612
/// This module is only used for testing purposes and should not be used in production code.
613
pub mod testing {
614
    use super::{should_we_bury_this, util, Error, Metadata, Path, PathBuf, Write};
615

616
    pub fn testable_should_we_bury_this(
617
        target: &Path,
618
        source: &PathBuf,
619
        metadata: &Metadata,
620
        stream: &mut impl Write,
621
    ) -> Result<bool, Error> {
622
        should_we_bury_this(target, source, metadata, &util::TestMode, stream)
623
    }
624
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc