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

MilesCranmer / rip2 / #797

02 Sep 2025 11:17PM UTC coverage: 58.14% (-5.0%) from 63.158%
#797

push

325 of 559 relevant lines covered (58.14%)

180557783.75 hits per line

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

55.07
/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> {
71✔
42
    args::validate_args(cli)?;
194✔
43
    let graveyard: &PathBuf = &get_graveyard(cli.graveyard.clone());
19✔
44

45
    if !graveyard.exists() {
×
46
        fs::create_dir_all(graveyard)?;
2,147,483,647✔
47

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

6✔
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 {
2,147,483,647✔
60
        // In force mode, skip the prompt to decompose
×
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.
3,346✔
66
        // This will be used to determine which items to remove from the
2,147,483,647✔
67
        // record following the unbury.
68
        // Initialize it with the targets passed to -r
2,147,483,647✔
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
            }
×
77
        }
1,952✔
78

3✔
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
        }
2,147,483,647✔
85

1✔
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 {
×
94
                PathBuf::from(&entry.orig)
×
95
            };
×
96
            let dirs_to_create = build_dirs_to_create_from_graveyard(&entry.dest, &orig);
×
97

98
            move_target(
×
99
                &entry.dest,
100
                &orig,
×
101
                allow_rename,
102
                &mode,
103
                stream,
×
104
                cli.force,
×
105
                &dirs_to_create,
×
106
            )
×
107
            .map_err(|e| {
×
108
                Error::new(
×
109
                    e.kind(),
×
110
                    format!(
111
                        "Unbury failed: couldn't copy files from {} to {}",
1✔
112
                        entry.dest.display(),
1✔
113
                        orig.display()
2✔
114
                    ),
1✔
115
                )
1✔
116
            })?;
1✔
117
            writeln!(
1✔
118
                stream,
119
                "Returned {} to {}",
120
                entry.dest.display(),
121
                orig.display()
×
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)? {
×
129
            let formatted_time = grave.format_time_for_display()?;
19✔
130
            writeln!(stream, "{}\t{}", formatted_time, grave.dest.display())?;
92✔
131
        }
12✔
132
    } else if cli.targets.is_empty() {
2,147,483,647✔
133
        Args::command().print_help()?;
5✔
134
    } else {
2,147,483,647✔
135
        let allow_rename = util::allow_rename();
136
        for target in &cli.targets {
2,147,483,647✔
137
            bury_target(
10✔
138
                target,
139
                graveyard,
2,147,483,647✔
140
                &record,
4✔
141
                cwd,
142
                cli.inspect,
4✔
143
                allow_rename,
4✔
144
                &mode,
4✔
145
                stream,
4✔
146
                cli.force,
4✔
147
            )?;
4✔
148
        }
4✔
149
    }
4✔
150

4✔
151
    Ok(())
152
}
153

154
#[allow(clippy::too_many_arguments)]
155
fn bury_target<const FILE_LOCK: bool>(
2,147,483,647✔
156
    target: &PathBuf,
157
    graveyard: &PathBuf,
158
    record: &Record<FILE_LOCK>,
159
    cwd: &Path,
×
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",
2,147,483,647✔
172
                target.to_str().unwrap()
×
173
            ),
×
174
        )
×
175
    })?;
×
176
    // Canonicalize the path unless it's a symlink
×
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"))?
2,147,483,647✔
182
    };
159✔
183

184
    if inspect && !should_we_bury_this(target, source, metadata, mode, stream)? {
2,147,483,647✔
185
        // User chose to not bury the file
70✔
186
    } else if source.starts_with(
187
        dunce::canonicalize(graveyard)
188
            .map_err(|e| Error::new(e.kind(), "Failed to canonicalize graveyard path"))?,
6✔
189
    ) {
190
        // If rip is called on a file already in the graveyard, prompt
2,147,483,647✔
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()
×
197
                ),
×
198
                mode,
×
199
                stream,
×
200
            )?
×
201
        {
202
            if fs::remove_dir_all(source).is_err() {
×
203
                fs::remove_file(source).map_err(|e| {
×
204
                    Error::new(e.kind(), format!("Couldn't unlink {}", source.display()))
205
                })?;
206
            }
×
207
        } else {
×
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)
2,147,483,647✔
219
            } else {
×
220
                dest
221
            }
×
222
        };
×
223

224
        let moved = move_target(
2,147,483,647✔
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

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

244
    Ok(())
×
245
}
246

247
fn should_we_bury_this(
248
    target: &Path,
2,147,483,647✔
249
    source: &PathBuf,
250
    metadata: &Metadata,
251
    mode: &impl util::TestingMode,
1✔
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!(
2✔
259
                    "Failed to get size of directory: {}",
260
                    source.display()
261
                ))
14✔
262
            })?;
24✔
263
            writeln!(
12✔
264
                stream,
12✔
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()
2,147,483,647✔
277
            .filter_map(Result::ok)
2,742✔
278
            .take(FILES_TO_INSPECT)
×
279
        {
×
280
            writeln!(stream, "{}", entry.path().display())?;
×
281
        }
×
282
    } else {
×
283
        writeln!(
284
            stream,
2,147,483,647✔
285
            "{}: file, {}",
286
            &target.to_str().unwrap(),
287
            util::humanize_bytes(metadata.len())
×
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)
2,147,483,647✔
295
            {
4✔
296
                writeln!(stream, "> {line}")?;
×
297
            }
×
298
        } else {
×
299
            writeln!(stream, "Error reading {}", source.display())?;
300
        }
2,147,483,647✔
301
    }
302
    util::prompt_yes(
303
        format!("Send {} to the graveyard?", target.to_str().unwrap()),
20✔
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

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

319
        // Process component for destination using shared logic
×
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 {
156✔
327
                    path: dest.clone(),
420✔
328
                    permissions,
140✔
329
                });
330
            }
420✔
331
        }
280✔
332
    }
140✔
333

334
    (dest, dirs_to_create)
335
}
336

337
/// Plan source directory structure and permissions
338
fn build_dirs_to_create_from_graveyard(
×
339
    graveyard_path: &Path,
340
    orig_path: &Path,
341
) -> Vec<DirToCreate> {
342
    let mut dirs_to_create = Vec::new();
824✔
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();
1,648✔
347

348
    while let (Some(g), Some(o)) = (graveyard_current, orig_current) {
349
        let permissions = fs::metadata(g).map(|m| m.permissions()).ok();
2,472✔
350
        dirs_to_create.push(DirToCreate {
2,472✔
351
            path: o.to_path_buf(),
352
            permissions,
803✔
353
        });
×
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.
824✔
364
///
824✔
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();
44✔
369

370
    // Create directories one by one in order (parent to child).
371
    // This assumes `dirs_to_create` is ordered from root to leaf.
544✔
372
    for dir in dirs_to_create {
98✔
373
        if dir.path.exists() {
374
            continue;
2,147,483,647✔
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
        })?;
×
383
        created.push(dir.clone());
384
    }
385

×
386
    Ok(created)
387
}
388

16✔
389
fn apply_dir_permissions(dirs: &[DirToCreate]) -> Result<(), Error> {
16✔
390
    for dir in dirs.iter().rev() {
32✔
391
        if let Some(perms) = &dir.permissions {
48✔
392
            fs::set_permissions(&dir.path, perms.clone()).map_err(|e| {
393
                Error::new(
394
                    e.kind(),
395
                    format!("Failed to set permissions on {}: {}", dir.path.display(), e),
396
                )
2,147,483,647✔
397
            })?;
14✔
398
        }
14✔
399
    }
28✔
400
    Ok(())
42✔
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,
348✔
408
    dest: &Path,
409
    allow_rename: bool,
410
    mode: &impl util::TestingMode,
411
    stream: &mut impl Write,
412
    force: bool,
413
    dirs_to_create: &[DirToCreate],
10✔
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() {
418
        return Ok(true);
419
    }
420

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

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

450
/// Move a target which is a directory to a given destination, copying if necessary.
2,147,483,647✔
451
/// Returns true *always*, as the creation of the directory is enough to mark it as successful.
452
pub fn move_dir(
453
    target: &Path,
454
    dest: &Path,
455
    mode: &impl util::TestingMode,
456
    stream: &mut impl Write,
15✔
457
    force: bool,
458
) -> Result<bool, Error> {
459
    let mut dest_dirs_and_perms: Vec<(PathBuf, fs::Permissions)> = Vec::new();
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
61✔
465
            .path()
466
            .strip_prefix(target)
1✔
467
            .map_err(|_| Error::other("Parent directory isn't a prefix of child directories?"))?;
468

2✔
469
        if entry.file_type().is_dir() {
7✔
470
            let dest_dir = dest.join(orphan);
471
            fs::create_dir_all(&dest_dir).map_err(|e| {
×
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(
92✔
486
                    e.kind(),
98✔
487
                    format!("Failed to get metadata for: {}", entry.path().display()),
196✔
488
                )
392✔
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| {
38✔
493
                Error::new(
38✔
494
                    e.kind(),
76✔
495
                    format!(
114✔
496
                        "Failed to copy file from {} to {}",
497
                        entry.path().display(),
498
                        dest.join(orphan).display()
499
                    ),
×
500
                )
×
501
            })?;
×
502
        }
×
503
    }
×
504
    fs::remove_dir_all(target).map_err(|e| {
×
505
        Error::new(
×
506
            e.kind(),
507
            format!("Failed to remove dir: {}", target.display()),
508
        )
509
    })?;
510

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

521
    Ok(true)
54✔
522
}
523

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

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

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

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

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

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

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

×
594
pub fn get_graveyard(graveyard: Option<PathBuf>) -> PathBuf {
2,147,483,647✔
595
    graveyard.unwrap_or_else(|| {
×
596
        if let Ok(env_graveyard) = env::var("RIP_GRAVEYARD") {
99✔
597
            PathBuf::from(env_graveyard)
2,147,483,647✔
598
        } else if let Ok(mut env_graveyard) = env::var("XDG_DATA_HOME") {
2,147,483,647✔
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");
603
            PathBuf::from(env_graveyard)
1,912✔
604
        } else {
2,868✔
605
            let user = util::get_user();
606
            env::temp_dir().join(format!("graveyard-{user}"))
607
        }
608
    })
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(
6✔
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)
36✔
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