File Coverage

blib/lib/File/Trash/FreeDesktop.pm
Criterion Covered Total %
statement 202 226 89.3
branch 96 130 73.8
condition 10 12 83.3
subroutine 19 19 100.0
pod 7 7 100.0
total 334 394 84.7


line stmt bran cond sub pod time code
1             package File::Trash::FreeDesktop;
2              
3 2     2   222186 use 5.010001;
  2         32  
4 2     2   10 use strict;
  2         8  
  2         41  
5 2     2   9 use warnings;
  2         7  
  2         44  
6 2     2   3612 use Log::ger;
  2         100  
  2         9  
7              
8 2     2   562 use Fcntl;
  2         6  
  2         401  
9 2     2   16 use File::MoreUtil qw(file_exists l_abs_path);
  2         4  
  2         6075  
10              
11             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
12             our $DATE = '2023-07-29'; # DATE
13             our $DIST = 'File-Trash-FreeDesktop'; # DIST
14             our $VERSION = '0.202'; # VERSION
15              
16             sub new {
17 2     2 1 2724 require File::HomeDir::FreeDesktop;
18              
19 2         8468 my ($class, %opts) = @_;
20              
21 2 50       19 my $home = File::HomeDir::FreeDesktop->my_home
22             or die "Can't get homedir, ".
23             "probably not a freedesktop-compliant environment?";
24 2         75 $opts{_home} = l_abs_path($home);
25              
26 2         74 bless \%opts, $class;
27             }
28              
29             sub _mk_trash {
30 12     12   27 my ($self, $trash_dir) = @_;
31 12         23 for ("", "/files", "/info") {
32 36         91 my $d = "$trash_dir$_";
33 36 100       414 unless (-d $d) {
34 3         15 log_trace("Creating directory %s ...", $d);
35 3 50       152 mkdir $d, 0700 or die "Can't mkdir $d: $!";
36             }
37             }
38             }
39              
40             sub _home_trash {
41 33     33   693 my ($self) = @_;
42 33         124 "$self->{_home}/.local/share/Trash";
43             }
44              
45             sub _mk_home_trash {
46 12     12   20 my ($self) = @_;
47 12         22 for (".local", ".local/share") {
48 24         71 my $d = "$self->{_home}/$_";
49 24 100       333 unless (-d $d) {
50 2 50       118 mkdir $d or die "Can't mkdir $d: $!";
51             }
52             }
53 12         63 $self->_mk_trash("$self->{_home}/.local/share/Trash");
54             }
55              
56             sub _select_trash {
57 12     12   571 require Sys::Filesystem::MountPoint;
58              
59 12         27387 my ($self, $file0) = @_;
60 12 50       30 file_exists($file0) or die "File doesn't exist: $file0";
61 12         245 my $afile = l_abs_path($file0);
62              
63             # since path_to_mount_point resolves symlink (sigh), we need to remove the
64             # leaf. otherwise: /mnt/sym -> / will cause mount point to become / instead
65             # of /mnt
66 12 100       434 my $afile2 = $afile; $afile2 =~ s!/[^/]+\z!! if (-l $file0);
  12         136  
67 12         52 my $file_mp = Sys::Filesystem::MountPoint::path_to_mount_point($afile2);
68              
69 12 50       8844 if ($ENV{PERL_FILE_TRASH_FREEDESKTOP_DEBUG}) {
70 0         0 log_trace "File's mountpoint for file $file0 is $file_mp";
71             }
72              
73             $self->{_home_mp} //= Sys::Filesystem::MountPoint::path_to_mount_point(
74 12   66     43 $self->{_home});
75              
76 12 50       341 if ($ENV{PERL_FILE_TRASH_FREEDESKTOP_DEBUG}) {
77 0         0 log_trace "Home mountpoint for file $file0 is $self->{_home_mp}";
78             }
79              
80             # try home trash
81 12 50       29 if ($self->{_home_mp} eq $file_mp) {
82 12         28 my $trash_dir = $self->_home_trash;
83 12         54 log_trace("Selected home trash for %s = %s", $afile, $trash_dir);
84 12         50 $self->_mk_home_trash;
85 12         47 return $trash_dir;
86             }
87              
88             # try file's mountpoint or mountpoint + "/tmp" (try "/tmp" first if /)
89 0         0 my $suggestion = '';
90 0 0       0 for my $dir ($file_mp eq '/' ?
91             ("/tmp", "/") : ($file_mp, "$file_mp/tmp")) {
92 0 0       0 unless (-w $dir) {
93 0 0       0 if ($ENV{PERL_FILE_TRASH_FREEDESKTOP_DEBUG}) {
94 0         0 log_trace "Directory $dir is not writable, skipped";
95             }
96 0         0 $suggestion = ", try making directory $dir writable?";
97 0         0 next;
98             }
99 0 0       0 if ($dir ne $file_mp) {
100 0         0 my $mp = Sys::Filesystem::MountPoint::path_to_mount_point($dir);
101 0 0       0 next unless $mp eq $file_mp;
102             }
103 0 0       0 my $trash_dir = ($dir eq "/" ? "" : $dir) . "/.Trash-$>";
104 0         0 log_trace("Selected trash for %s = %s", $afile, $trash_dir);
105 0         0 $self->_mk_trash($trash_dir);
106 0         0 return $trash_dir;
107             }
108              
109 0         0 die "Can't find suitable trash dir$suggestion";
110             }
111              
112             sub list_trashes {
113 20     20 1 110 require List::Util;
114 20         606 require Sys::Filesystem;
115              
116 20         26604 my ($self) = @_;
117              
118 20         131 my $sysfs = Sys::Filesystem->new;
119 20         42232 my @mp = $sysfs->filesystems;
120              
121 20         114 my @res = map { l_abs_path($_) }
122 2980         27869 grep {-d} (
123             $self->_home_trash,
124 20         1020 (map { (
125 740         11869 "$_/.Trash-$>",
126             "$_/tmp/.Trash-$>",
127             "$_/.Trash/$>",
128             "$_/tmp/.Trash/$>",
129             ) } @mp)
130             );
131              
132 20         1952 List::Util::uniq(@res);
133             }
134              
135             sub _parse_trashinfo {
136 33     33   759 require Time::Local;
137              
138             # we use regex parsing instead of INI to be simpler
139 33         1817 my ($self, $content) = @_;
140 33 50       165 $content =~ /\A\[Trash Info\]/m or return "No header line";
141 33         68 my $res = {};
142 33 50       142 $content =~ /^Path=(.+)/m or return "No Path line";
143 33         140 $res->{path} = $1;
144             PARSE_DELETIONDATE: {
145 33         57 $content =~ /^DeletionDate=(\d{4})-?(\d{2})-?(\d{2})T(\d\d):(\d\d):(\d\d)$/m
146 33 50       166 or do { warn "No/invalid DeletionDate line for path $res->{path}"; last PARSE_DELETIONDATE };
  0         0  
  0         0  
147             $res->{deletion_date} = Time::Local::timelocal(
148             $6, $5, $4, $3, $2-1, $1-1900)
149 33 50       185 or do { warn "Invalid deletion date: $1-$2-$3T$4-$5-$6 when parsing trashinfo for path $res->{path}"; last PARSE_DELETIONDATE };
  0         0  
  0         0  
150             }
151 33         2583 $res;
152             }
153              
154             sub list_contents {
155 25     25 1 10146 my $self = shift;
156              
157 25         38 my $opts;
158 25 100       72 if (ref($_[0]) eq 'HASH') {
159 23         39 $opts = shift;
160             } else {
161 2         6 $opts = {};
162             }
163 25         52 my ($trash_dir0) = @_;
164              
165 25 100       73 my @trash_dirs = $trash_dir0 ? ($trash_dir0) : ($self->list_trashes);
166 25         56 my @res;
167 25         52 my ($path_wc_re, $filename_wc_re);
168             L1:
169 25         65 for my $trash_dir (@trash_dirs) {
170             #next unless -d $trash_dir;
171             #next unless -d "$trash_dir/info";
172             opendir my($dh), "$trash_dir/info"
173 25 100       991 or do { warn "Can't read trash info dir $trash_dir/info: $!"; next };
  1         88  
  1         10  
174             ENTRY:
175 24         520 for my $e (readdir $dh) {
176 81 100       481 next unless $e =~ /\.trashinfo$/;
177 33         145 local $/;
178 33         84 my $ifile = "$trash_dir/info/$e";
179 33 50       1141 open my($fh), "<", $ifile
180             or die "Can't open trash info file $e: $!";
181 33         832 my $content = <$fh>;
182 33         377 close $fh;
183 33         136 my $parse_res = $self->_parse_trashinfo($content);
184 33 50       91 die "Can't parse trash info file $e: $parse_res" unless ref($parse_res);
185              
186             FILTER: {
187 33 100       54 if (defined $opts->{path}) {
  33         80  
188 20 100       70 next ENTRY unless $parse_res->{path} eq $opts->{path};
189             }
190 30 100       68 if (defined $opts->{path_wildcard}) {
191 2 100       7 unless (defined $path_wc_re) {
192 1         620 require String::Wildcard::Bash;
193 1         1807 $path_wc_re = String::Wildcard::Bash::convert_wildcard_to_re($opts->{path_wildcard});
194             }
195 2 100       169 next ENTRY unless $parse_res->{path} =~ $path_wc_re;
196             }
197 29 100       57 if (defined $opts->{path_re}) {
198 2 100       22 next ENTRY unless $parse_res->{path} =~ $opts->{path_re};
199             }
200             FILTER_FILENAME: {
201 28         42 (my $filename = $parse_res->{path}) =~ s!.+/!!;
  28         188  
202 28 100       82 if (defined $opts->{filename}) {
203 2 100       26 next ENTRY unless $filename eq $opts->{filename};
204             }
205 27 100       53 if (defined $opts->{filename_wildcard}) {
206 2 100       6 unless (defined $filename_wc_re) {
207 1         6 require String::Wildcard::Bash;
208 1         5 $filename_wc_re = String::Wildcard::Bash::convert_wildcard_to_re($opts->{filename_wildcard});
209             }
210 2 100       134 next ENTRY unless $filename =~ $filename_wc_re;
211             }
212 26 100       60 if (defined $opts->{filename_re}) {
213 2 100       38 next ENTRY unless $filename =~ $opts->{filename_re};
214             }
215             } # FILTER_FILENAME
216             } # FILTER
217              
218 25         64 my $afile = "$trash_dir/files/$e"; $afile =~ s/\.trashinfo\z//;
  25         97  
219 25 100       64 if (defined $opts->{mtime}) {
220 5         77 my @st = lstat($afile);
221 5 100 66     63 next ENTRY unless !@st || $st[9] == $opts->{mtime};
222             }
223 22 100       44 if (defined $opts->{suffix}) {
224 5 100       99 next ENTRY unless $afile =~ /\.\Q$opts->{suffix}\E\z/;
225             }
226 19         43 $parse_res->{trash_dir} = $trash_dir;
227 19         56 $e =~ s/\.trashinfo//; $parse_res->{entry} = $e;
  19         41  
228 19         240 push @res, $parse_res;
229             }
230             }
231              
232             @res = sort {
233 25         92 $a->{deletion_date} <=> $b->{deletion_date} ||
234             $a->{entry} cmp $b->{entry}
235 1 0       8 } @res;
236              
237 25         123 @res;
238             }
239              
240             sub trash {
241 14     14 1 23803 my $self = shift;
242 14         24 my $opts;
243 14 100       44 if (ref($_[0]) eq 'HASH') {
244 3         13 $opts = shift;
245             } else {
246 11         26 $opts = {};
247             }
248 14   100     97 $opts->{on_not_found} //= 'die';
249 14         41 my ($file0) = @_;
250              
251 14 100       66 unless (file_exists $file0) {
252 2 100       69 if ($opts->{on_not_found} eq 'ignore') {
253 1         13 return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
254             } else {
255 1         24 die "File does not exist: $file0";
256             }
257             }
258 12         275 my $afile = l_abs_path($file0);
259 12         361 my $trash_dir = $self->_select_trash($afile);
260              
261             # try to create info/NAME first
262 12 50       29 my $name0 = $afile; $name0 =~ s!.*/!!; $name0 = "WTF" unless length($name0);
  12         80  
  12         37  
263 12         22 my $name;
264             my $fh;
265 12 100       19 my $i = 1; my $limit = defined($opts->{suffix}) ? 1 : 1000;
  12         33  
266 12         18 my $tinfo;
267 12         16 while (1) {
268 14 100       50 $name = $name0 . (defined($opts->{suffix}) ? ".$opts->{suffix}" :
    100          
269             ($i > 1 ? ".$i" : ""));
270 14         35 $tinfo = "$trash_dir/info/$name.trashinfo";
271 14 100       880 last if sysopen($fh, $tinfo, O_WRONLY | O_EXCL | O_CREAT);
272 2 50       14 die "Can't create trash info file $name.trashinfo in $trash_dir: $!"
273             if $i >= $limit;
274 2         5 $i++;
275             }
276 12         75 my $tfile = "$trash_dir/files/$name";
277              
278 12         295 my @t = localtime();
279 12         97 my $ts = sprintf("%04d%02d%02dT%02d:%02d:%02d",
280             $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
281 12         362 syswrite($fh, "[Trash Info]\nPath=$afile\nDeletionDate=$ts\n");
282 12 50       166 close $fh or die "Can't write trash info for $name in $trash_dir: $!";
283              
284 12         64 log_trace("Trashing %s -> %s ...", $afile, $tfile);
285 12 50       458 unless (rename($afile, $tfile)) {
286 0         0 unlink "$trash_dir/info/$name.trashinfo";
287 0         0 die "Can't rename $afile to $tfile: $!";
288             }
289              
290 12         111 $tfile;
291             }
292              
293             sub recover {
294 16     16 1 14324 my $self = shift;
295 16         30 my $opts;
296 16 100       64 if (ref($_[0]) eq 'HASH') {
297 9         25 $opts = shift;
298             } else {
299 7         14 $opts = {};
300             }
301 16   100     98 $opts->{on_not_found} //= 'die';
302 16   100     72 $opts->{on_target_exists} //= 'die';
303 16         43 my ($file0, $trash_dir0) = @_;
304              
305 16 100       51 if (file_exists($file0)) {
306 3 100       119 if ($opts->{on_target_exists} eq 'ignore') {
307 1         6 return 0;
308             } else {
309 2         29 die "Restore target already exists: $file0";
310             }
311             }
312 13         291 my $afile = l_abs_path($file0);
313              
314             my @res = $self->list_contents({
315             path => $afile,
316             mtime => $opts->{mtime},
317             suffix => $opts->{suffix},
318 13         393 }, $trash_dir0);
319 13 100       46 unless (@res) {
320 4 100       37 if ($opts->{on_not_found} eq 'ignore') {
321 1         8 return 0;
322             } else {
323 3         39 die "File not found in trash: $file0";
324             }
325             }
326              
327 9         24 my $trash_dir = $res[0]{trash_dir};
328 9         25 my $ifile = "$trash_dir/info/$res[0]{entry}.trashinfo";
329 9         19 my $tfile = "$trash_dir/files/$res[0]{entry}";
330 9         30 log_trace("Recovering from trash %s -> %s ...", $tfile, $afile);
331 9 50       339 unless (rename($tfile, $afile)) {
332 0         0 die "Can't rename $tfile to $afile: $!";
333             }
334 9         479 unlink($ifile);
335             }
336              
337             sub _erase {
338 4     4   542 require File::Remove;
339              
340 4         2244 my ($self, $file0, $trash_dir) = @_;
341 4 100       17 my $afile = defined($file0) ? l_abs_path($file0) : undef;
342              
343 4         62 my @ct = $self->list_contents({path=>$afile}, $trash_dir);
344              
345 4         11 my @res;
346 4         10 for (@ct) {
347 2         7 my $f = "$_->{trash_dir}/info/$_->{entry}.trashinfo";
348 2 50       106 unlink $f or die "Can't remove $f: $!";
349             # XXX File::Remove interprets wildcard, what if filename contains
350             # wildcard?
351 2         18 File::Remove::remove(\1, "$_->{trash_dir}/files/$_->{entry}");
352 2         5182 push @res, $_->{path};
353             }
354 4         39 @res;
355             }
356              
357             sub erase {
358 1     1 1 2262 my ($self, $file, $trash_dir) = @_;
359              
360 1 50       5 die "Please specify file" unless defined $file;
361 1         5 $self->_erase($file, $trash_dir);
362             }
363              
364             # XXX currently empty calls _erase, which parses .trashinfo files. this is
365             # useless overhead.
366             sub empty {
367 3     3 1 1514 my ($self, $trash_dir) = @_;
368              
369 3         15 $self->_erase(undef, $trash_dir);
370             }
371              
372             1;
373             # ABSTRACT: Trash files
374              
375             __END__