| File: | blib/lib/App/FileSync/Trash.pm |
| Coverage: | 87.3% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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. | |||||
| 30 | Readonly my $YEAR => qr/\A\d{4}\z/; | |||||
| 31 | Readonly my $MONTH => qr/\A\d{2}\z/; | |||||
| 32 | Readonly my $DAY => qr/\A\d{2}\z/; | |||||
| 33 | ||||||
| 34 | # Windows has no POSIX permission bits worth restoring on a directory. | |||||
| 35 | Readonly 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 | ||||||
| 109 | sub 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 | ||||||
| 188 | sub 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 | ||||||
| 259 | sub 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 | ||||||
| 336 | sub 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 | ||||||
| 389 | sub 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 | ||||||
| 444 | sub 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. | |||||
| 462 | sub _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. | |||||
| 476 | sub _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. | |||||
| 504 | sub _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. | |||||
| 532 | sub _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 | ||||||
| 538 | 1; | |||||
| 539 | ||||||