File Coverage

File:blib/lib/App/FileSync/Trash.pm
Coverage:87.3%

linestmtbrancondsubtimecode
1package App::FileSync::Trash;
2
3# Deleted files are never unlinked: they move into a dated trash tree
4# (ROOT/YYYY/MM/DD/<relative path>) and are pruned a whole day at a time once
5# they are older than the retention period.
6
7
316
316
316
76008
245
4237
use App::FileSync::Platform;
8
9
316
316
316
511
232
2433
use strict;
10
316
316
316
428
227
6058
use warnings;
11
316
316
316
645
6187
602
use autodie qw(:all);
12
13
316
316
316
673959
282
7788
use File::Basename qw(basename dirname);
14
316
316
316
807
1546
2722
use File::Copy ();
15
316
316
316
543
220
6855
use File::Path qw(remove_tree);
16
316
316
316
515
244
2745
use File::Spec;
17
316
316
316
686
1909
5833
use Readonly;
18
316
316
316
710
27837
967
use Sub::Private { mode => 'enforce' };
19
316
316
316
32227
238
1883
use Time::HiRes ();
20
316
316
316
716
955
3044
use Time::Local ();
21
22
316
316
316
694
295
6023
use App::FileSync::Args qw(named);
23
316
316
316
691
220
13691
use App::FileSync::Constants qw($MODE_MASK $TRASH_DAYS $SECONDS_PER_DAY);
24
316
316
316
615
242
6356
use App::FileSync::I18N qw(throw os_error);
25
316
316
316
693
239
5751
use App::FileSync::IO qw(copy_preserving ensure_dir list_dir);
26
27
316
316
316
558
217
693
use version; our $VERSION = version->declare('0.001.1');
28
29# The three levels of a day bucket.
30Readonly my $YEAR  => qr/\A\d{4}\z/;
31Readonly my $MONTH => qr/\A\d{2}\z/;
32Readonly my $DAY   => qr/\A\d{2}\z/;
33
34# Windows has no POSIX permission bits worth restoring on a directory.
35Readonly my $HAS_MODES => $^O ne 'MSWin32';
36
37=encoding utf8
38
39 - 107
=head1 NAME

App::FileSync::Trash - dated trash with retention

=head1 SYNOPSIS

        my $where = App::FileSync::Trash::move_to_trash(
                src => $abs, rel => 'notes/a.txt', root => $trash_dir,
        );
        my $versions = App::FileSync::Trash::versions(root => $trash_dir, rel => 'notes/a.txt');
        App::FileSync::Trash::restore(src => $versions->[0], to => $abs);
        App::FileSync::Trash::prune(root => $trash_dir, days => 30);

=head1 FUNCTIONS

=head2 move_to_trash

Purpose: move a file or directory into today's trash bucket.

Args: C<src> (absolute path), C<rel> (its relative path, kept inside the
bucket), C<root> (the trash directory), optional C<epoch> (default now).

Returns: the absolute path it was moved to.  A second deletion of the same
path on the same day is kept as C<PATH.1>, then C<PATH.2>, and so on.

Side Effects: renames C<src>; across filesystems a file is copied (keeping its
mtime) and the original removed.

Usage:

        my $where = App::FileSync::Trash::move_to_trash(src => $abs, rel => $rel, root => $trash);

=head3 EXAMPLE

        App::FileSync::Trash::move_to_trash(
                src => '/home/foo/Documents/old.txt', rel => 'old.txt',
                root => '/home/foo/.filesync/trash/documents', epoch => time,
        );

=head3 API SPECIFICATION

=head4 INPUT

        {
                src   => { type => 'string', min => 1 },
                rel   => { type => 'string', min => 1 },
                root  => { type => 'string', min => 1 },
                epoch => { type => 'number', optional => 1 },
        }

=head4 OUTPUT

        { type => 'string', min => 1 }

=head3 MESSAGES

        Key           Meaning                                   Resolution
        trash.move    the entry could not be moved              check permissions
        io.mkdir      the bucket could not be created           check permissions

=head3 FORMAL SPECIFICATION

        Î”FS
        src?, rel?, root? : PATH ; t? : â„• ; dst! : PATH
        let b = root? / day(t?) / rel?
        dst! = b if b ∉ dom FS, else b ⁀ "." ⁀ min { n : ℕ₁ | b ⁀ "." ⁀ n ∉ dom FS }
        FS'(dst!) = FS(src?) ∧ src? ∉ dom FS'

=cut
108
109sub move_to_trash {
110
160
163037
        my $args = named(\@_, {
111                src   => { type => 'string', min => 1 },
112                rel   => { type => 'string', min => 1 },
113                root  => { type => 'string', min => 1 },
114                epoch => { type => 'number', optional => 1 },
115        }, 'Trash::move_to_trash');
116
117
160
262
        my $src = $args->{src};
118
160
202
        my $epoch = defined $args->{epoch} ? $args->{epoch} : time;
119
120
160
224
        my $destination = File::Spec->catfile(_bucket($args->{root}, $epoch), split m{/}, $args->{rel});
121
160
2965
        ensure_dir(dirname($destination));
122
123        # Two deletions of the same path on the same day keep both.
124
160
140
        my $final = $destination;
125
160
120
        my $suffix = 0;
126
160
12862
        $final = $destination . '.' . ++$suffix while -e $final || -l $final;
127
128        # File::Copy::move renames when it can and otherwise copies (keeping the
129        # mtime) and unlinks; a directory has to rename in one step.
130
160
128
        local $@;
131        my $moved = -d $src && !-l $src
132
160
0
0
704
0
0
                ? eval { rename $src, $final; 1 }
133                : File::Copy::move($src, $final);
134
160
5292
        throw('trash.move', { from => $src, to => $final, error => os_error($@) }) unless $moved;
135
136
160
453
        return $final;
137}
138
139 - 186
=head2 prune

Purpose: remove day buckets older than the retention period.

Args: C<root>, optional C<days> (default 30; 0 or less disables pruning),
C<now> (default the current time), C<dry_run>.

Returns: an arrayref of the bucket directories removed (or that would be).

Side Effects: removes whole day directories.  A bucket that cannot be removed
is left and not reported.

Usage:

        my $removed = App::FileSync::Trash::prune(root => $trash, days => 30);

=head3 EXAMPLE

        my $would = App::FileSync::Trash::prune(root => $trash, days => 7, dry_run => 1);
        print "$_\n" for @{$would};

=head3 API SPECIFICATION

=head4 INPUT

        {
                root    => { type => 'string', optional => 1 },
                days    => { type => 'number', optional => 1 },
                now     => { type => 'number', optional => 1 },
                dry_run => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        Î”FS
        Old == { b : buckets(root) | midnight(b) ≤ now − days × 86400 }
        dry_run ⇒ FS' = FS ∧ result = Old
        Â¬dry_run ⇒ result ⊆ Old ∧ ∀ b : result • b ∉ dom FS'

=cut
187
188sub prune {
189
453
6930
        my $args = named(\@_, {
190                root    => { type => 'string', optional => 1 },
191                days    => { type => 'number', optional => 1 },
192                now     => { type => 'number', optional => 1 },
193                dry_run => { type => 'boolean', optional => 1 },
194        }, 'Trash::prune');
195
196
453
1018
        my $root = $args->{root};
197
453
796
        my $days = defined $args->{days} ? $args->{days} : $TRASH_DAYS;
198
453
5000
        return [] unless defined $root && length $root && -d $root && $days > 0;
199
200
209
720
        my $cutoff = (defined $args->{now} ? $args->{now} : time) - $days * $SECONDS_PER_DAY;
201
202
209
611
        my @removed;
203
209
209
212
496
        for my $bucket (@{ _buckets($root) }) {
204                # Compare whole days so a mid-day run does not prune a bucket that
205                # only just crossed the boundary.
206
36
36
60
80
                next if _midnight_epoch(@{$bucket}{qw(year month day)}) > $cutoff;
207
208
6
898
                my $gone = $args->{dry_run} || remove_tree($bucket->{path}, { error => \my $errors });
209
6
17
                push @removed, $bucket->{path} if $gone;
210        }
211
212
209
1397
        return \@removed;
213}
214
215 - 257
=head2 versions

Purpose: every stored version of a path.

Args: C<root>, C<rel>.

Returns: an arrayref of absolute paths, newest first: by day, then by the
same-day suffix (C<.2> before C<.1> before the unsuffixed first deletion).

Side Effects: none.

Usage:

        my $versions = App::FileSync::Trash::versions(root => $trash, rel => 'notes/a.txt');

=head3 EXAMPLE

        my ($newest) = @{ App::FileSync::Trash::versions(root => $trash, rel => 'a.txt') };

=head3 API SPECIFICATION

=head4 INPUT

        {
                root => { type => 'string', optional => 1 },
                rel  => { type => 'string', min => 1 },
        }

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

        Key            Meaning              Resolution
        args.invalid   no rel was given     pass the path

=head3 FORMAL SPECIFICATION

        versions(root, rel) = sort_desc(day, suffix)
                { v | ∃ b : buckets(root) • v = b/rel ∨ ∃ n : ℕ₁ • v = b/rel ⁀ "." ⁀ n }

=cut
258
259sub versions {
260
38
1394
        my $args = named(\@_, {
261                root => { type => 'string', optional => 1 },
262                rel  => { type => 'string', min => 1 },
263        }, 'Trash::versions');
264
265
38
59
        my $root = $args->{root};
266
38
290
        return [] unless defined $root && -d $root;
267
268
36
57
        my @parts = split m{/}, $args->{rel};
269
36
39
        my $name = pop @parts;
270
36
318
        my $stored = qr/\A\Q$name\E(?:\.(\d+))?\z/;
271
272        # Collect [ path, date, suffix ] so the order is numeric: a plain string
273        # sort would put "a.txt.10" before "a.txt.9".
274
36
35
        my @found;
275
36
36
48
50
        for my $bucket (@{ _buckets($root) }) {
276
29
106
                my $dir = File::Spec->catdir($bucket->{path}, @parts);
277
29
29
28
37
                for my $entry (@{ list_dir($dir) || [] }) {
278
138
223
                        next unless $entry =~ $stored;
279
128
430
                        push @found, [ File::Spec->catfile($dir, $entry), $bucket->{date}, defined $1 ? $1 : 0 ];
280                }
281        }
282
283
36
380
70
245
        my @newest_first = sort { $b->[1] cmp $a->[1] || $b->[2] <=> $a->[2] } @found;
284
36
128
62
173
        return [ map { $_->[0] } @newest_first ];
285}
286
287 - 334
=head2 restore

Purpose: copy one trashed version out, keeping modes and mtimes.

Args: C<src> (a path from L</versions>), C<to> (the destination; must not exist).

Returns: C<to>.

Side Effects: copies a file, or a directory tree, to C<to>.  The trash entry is
left in place, so the same version can be restored twice.

Usage:

        App::FileSync::Trash::restore(src => $versions->[0], to => $abs);

=head3 EXAMPLE

        App::FileSync::Trash::restore(src => $version, to => '/tmp/recovered.txt');

=head3 API SPECIFICATION

=head4 INPUT

        {
                src => { type => 'string', min => 1 },
                to  => { type => 'string', min => 1 },
        }

=head4 OUTPUT

        { type => 'string', min => 1 }

=head3 MESSAGES

        Key                  Meaning                              Resolution
        trash.missing        the version is not there             list versions again
        trash.exists         the destination already exists       move it aside
        trash.cannot_stat    an entry cannot be examined          check permissions
        trash.not_regular    a special file is in the version     restore by hand
        io.copy              a file could not be copied           check space

=head3 FORMAL SPECIFICATION

        Î”FS
        pre src ∈ dom FS ∧ to ∉ dom FS
        post tree'(to) = tree(src) ∧ src ∈ dom FS'

=cut
335
336sub restore {
337
9
39
        my $args = named(\@_, {
338                src => { type => 'string', min => 1 },
339                to  => { type => 'string', min => 1 },
340        }, 'Trash::restore');
341
9
9
20
14
        my ($src, $to) = @{$args}{qw(src to)};
342
343
9
47
        throw('trash.missing', { path => $src }) unless -e $src || -l $src;
344
9
50
        throw('trash.exists', { path => $to }) if -e $to || -l $to;
345
346
9
16
        _restore_entry($src, $to);
347
9
13
        return $to;
348}
349
350 - 387
=head2 date_of

Purpose: the trash day a stored path belongs to.

Args: C<$path>, as returned by L</versions> or L</move_to_trash>.

Returns: C<YYYY-MM-DD>, or C<undef> for a path outside a day bucket.  Both
C</> and C<\> separators are understood.

Side Effects: none.

Usage:

        my $date = App::FileSync::Trash::date_of($version);

=head3 EXAMPLE

        print App::FileSync::Trash::date_of('/t/trash/2026/10/22/a/b.txt'), "\n";     # 2026-10-22

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'path', type => 'string', optional => 1 } ]

=head4 OUTPUT

        { type => 'string', optional => 1, matches => qr/\A\d{4}-\d{2}-\d{2}\z/ }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        date_of(p ⁀ sep ⁀ yyyy ⁀ sep ⁀ mm ⁀ sep ⁀ dd ⁀ sep ⁀ q) = yyyy ⁀ "-" ⁀ mm ⁀ "-" ⁀ dd

=cut
388
389sub date_of {
390
21
1379
        my ($path) = @_;
391
392
21
82
        my @date = defined $path ? $path =~ m{[/\\](\d{4})[/\\](\d{2})[/\\](\d{2})[/\\]} : ();
393
21
175
        return @date ? join('-', @date) : undef;
394}
395
396 - 442
=head2 version_info

Purpose: describe one trashed version for a listing.

Args: C<$path>, a path from L</versions>.

Returns: C<{ path, date, size, mtime }>; C<size> and C<mtime> are C<undef> if
the version has gone since it was listed.

Side Effects: stats the path.

Usage:

        my @rows = map { App::FileSync::Trash::version_info($_) } @{$versions};

=head3 EXAMPLE

        my $info = App::FileSync::Trash::version_info($versions->[0]);
        print "$info->{date} $info->{size} bytes\n";

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'path', type => 'string', min => 1 } ]

=head4 OUTPUT

        {
                type => 'hashref',
                schema => {
                        path  => { type => 'string' },
                        date  => { type => 'string', optional => 1 },
                        size  => { type => 'integer', optional => 1 },
                        mtime => { type => 'number', optional => 1 },
                },
        }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        version_info(p) = ⟨path ↦ p, date ↦ date_of(p), size ↦ size(FS(p)), mtime ↦ mtime(FS(p))⟩

=cut
443
444sub version_info {
445
6
10
        my ($path) = @_;
446
447        # A listing races with prune, so a vanished version is reported, not fatal.
448
6
43
        my @stat = Time::HiRes::stat($path);
449        return {
450
6
15
                path  => $path,
451                date  => date_of($path),
452                size  => @stat ? $stat[7] : undef,
453                mtime => @stat ? $stat[9] : undef,
454        };
455}
456
457# _bucket
458# Purpose:     the day directory for an epoch.
459# Entry:       $root is the trash root; $epoch a time.
460# Exit:        returns ROOT/YYYY/MM/DD (UTC).
461# Side effects: none.
462sub _bucket :Private {
463        my ($root, $epoch) = @_;
464
465        my @t = gmtime($epoch);
466        return File::Spec->catdir($root,
467                sprintf('%04d', $t[5] + 1900), sprintf('%02d', $t[4] + 1), sprintf('%02d', $t[3]));
468
316
316
316
205340
265
5889
}
469
470# _buckets
471# Purpose:     every day bucket under a trash root.
472# Entry:       $root exists.
473# Exit:        returns an arrayref of { path, year, month, day, date },
474#              oldest first.
475# Side effects: none.  Stray names at any level are ignored.
476sub _buckets :Private {
477        my ($root) = @_;
478
479        my @buckets;
480        for my $year (grep { $_ =~ $YEAR } @{ list_dir($root) || [] }) {
481                my $year_dir = File::Spec->catdir($root, $year);
482                for my $month (grep { $_ =~ $MONTH } @{ list_dir($year_dir) || [] }) {
483                        my $month_dir = File::Spec->catdir($year_dir, $month);
484                        for my $day (grep { $_ =~ $DAY } @{ list_dir($month_dir) || [] }) {
485                                push @buckets, {
486                                        path  => File::Spec->catdir($month_dir, $day),
487                                        year  => $year,
488                                        month => $month,
489                                        day   => $day,
490                                        date  => "$year-$month-$day",
491                                };
492                        }
493                }
494        }
495
496        return \@buckets;
497
316
316
316
116080
248
2680
}
498
499# _restore_entry
500# Purpose:     copy a trashed file or directory tree out of the trash.
501# Entry:       $src exists; $to does not (or is a directory being filled).
502# Exit:        returns 1.
503# Side effects: creates $to; raises trash.cannot_stat or trash.not_regular.
504sub _restore_entry :Private {
505        my ($src, $to) = @_;
506
507        my @stat = Time::HiRes::lstat($src);
508        throw('trash.cannot_stat', { path => $src, error => os_error() }) unless @stat;
509
510        if (-d _) {
511                ensure_dir($to);
512                _restore_entry(File::Spec->catfile($src, $_), File::Spec->catfile($to, $_))
513                        for @{ list_dir($src) || [] };
514
515                # Directory metadata last: filling it changed its mtime.
516                chmod($stat[2] & $MODE_MASK, $to) if $HAS_MODES;
517                utime($stat[9], $stat[9], $to);
518                return 1;
519        }
520
521        throw('trash.not_regular', { path => $src }) unless -f _;
522        copy_preserving($src, $to, \@stat);
523
524        return 1;
525
316
316
316
105490
263
2566
}
526
527# _midnight_epoch
528# Purpose:     the UTC epoch at the start of a bucket's day.
529# Entry:       numeric year, month (1-12) and day strings from a bucket path.
530# Exit:        returns the epoch.
531# Side effects: none.
532sub _midnight_epoch :Private {
533        my ($year, $month, $day) = @_;
534
535        return Time::Local::timegm(0, 0, 0, $day, $month - 1, $year);
536
316
316
316
85343
263
2275
}
537
5381;
539