File Coverage

blib/lib/File/Trash/FreeDesktop.pm
Criterion Covered Total %
statement 202 227 88.9
branch 93 128 72.6
condition 19 36 52.7
subroutine 19 19 100.0
pod 7 7 100.0
total 340 417 81.5


line stmt bran cond sub pod time code
1             package File::Trash::FreeDesktop;
2              
3 2     2   222352 use 5.010001;
  2         25  
4 2     2   11 use strict;
  2         3  
  2         40  
5 2     2   9 use warnings;
  2         4  
  2         60  
6 2     2   3697 use Log::ger;
  2         105  
  2         9  
7              
8 2     2   525 use Fcntl;
  2         4  
  2         414  
9 2     2   16 use File::MoreUtil qw(file_exists l_abs_path);
  2         5  
  2         6412  
10              
11             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
12             our $DATE = '2023-08-07'; # DATE
13             our $DIST = 'File-Trash-FreeDesktop'; # DIST
14             our $VERSION = '0.205'; # VERSION
15              
16             sub new {
17 2     2 1 2839 require File::HomeDir::FreeDesktop;
18              
19 2         8293 my ($class, %opts) = @_;
20              
21 2 50       20 my $home = File::HomeDir::FreeDesktop->my_home
22             or die "Can't get homedir, ".
23             "probably not a freedesktop-compliant environment?";
24 2         77 $opts{_home} = l_abs_path($home);
25              
26 2         88 bless \%opts, $class;
27             }
28              
29             sub _mk_trash {
30 17     17   48 my ($self, $trash_dir) = @_;
31 17         35 for ("", "/files", "/info") {
32 51         130 my $d = "$trash_dir$_";
33 51 100       573 unless (-d $d) {
34 3         14 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 39     39   710 my ($self) = @_;
42 39         176 "$self->{_home}/.local/share/Trash";
43             }
44              
45             sub _mk_home_trash {
46 17     17   30 my ($self) = @_;
47 17         40 for (".local", ".local/share") {
48 34         93 my $d = "$self->{_home}/$_";
49 34 100       454 unless (-d $d) {
50 2 50       162 mkdir $d or die "Can't mkdir $d: $!";
51             }
52             }
53 17         97 $self->_mk_trash("$self->{_home}/.local/share/Trash");
54             }
55              
56             sub _select_trash {
57 17     17   635 require Sys::Filesystem::MountPoint;
58              
59 17         27874 my ($self, $file0) = @_;
60 17 50       38 file_exists($file0) or die "File doesn't exist: $file0";
61 17         317 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 17 100       606 my $afile2 = $afile; $afile2 =~ s!/[^/]+\z!! if (-l $file0);
  17         185  
67 17         99 my $file_mp = Sys::Filesystem::MountPoint::path_to_mount_point($afile2);
68              
69 17 50       9958 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 17   66     56 $self->{_home});
75              
76 17 50       348 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 17 50       44 if ($self->{_home_mp} eq $file_mp) {
82 17         54 my $trash_dir = $self->_home_trash;
83 17         82 log_trace("Selected home trash for %s = %s", $afile, $trash_dir);
84 17         76 $self->_mk_home_trash;
85 17         75 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 21     21 1 115 require List::Util;
114 21         578 require Sys::Filesystem;
115              
116 21         26589 my ($self) = @_;
117              
118 21         113 my $sysfs = Sys::Filesystem->new;
119 21         43038 my @mp = $sysfs->filesystems;
120              
121 21         95 my @res = map { l_abs_path($_) }
122 169         2150 grep {-d} (
123             $self->_home_trash,
124             (
125 21 100       1030 $self->{home_only} ? () : (map { (
126 37         578 "$_/.Trash-$>",
127             "$_/tmp/.Trash-$>",
128             "$_/.Trash/$>",
129             "$_/tmp/.Trash/$>",
130             ) } @mp)
131             )
132             );
133              
134 21         1814 List::Util::uniq(@res);
135             }
136              
137             sub _parse_trashinfo {
138 38     38   781 require Time::Local;
139              
140             # we use regex parsing instead of INI to be simpler
141 38         1966 my ($self, $content) = @_;
142 38 50       187 $content =~ /\A\[Trash Info\]/m or return "No header line";
143 38         86 my $res = {};
144 38 50       165 $content =~ /^Path=(.+)/m or return "No Path line";
145 38         160 $res->{path} = $1;
146             PARSE_DELETIONDATE: {
147 38         77 $content =~ /^DeletionDate=(\d{4})-?(\d{2})-?(\d{2})T(\d\d):(\d\d):(\d\d)$/m
148 38 50       201 or do { warn "No/invalid DeletionDate line for path $res->{path}"; last PARSE_DELETIONDATE };
  0         0  
  0         0  
149             $res->{deletion_date} = Time::Local::timelocal(
150             $6, $5, $4, $3, $2-1, $1-1900)
151 38 50       218 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  
152             }
153 38         3064 $res;
154             }
155              
156             sub list_contents {
157 26     26 1 10471 my $self = shift;
158              
159 26         40 my $opts;
160 26 100       68 if (ref($_[0]) eq 'HASH') {
161 24         37 $opts = shift;
162             } else {
163 2         6 $opts = {};
164             }
165 26         54 my ($trash_dir0) = @_;
166              
167 26 100       80 my @trash_dirs = $trash_dir0 ? ($trash_dir0) : ($self->list_trashes);
168 26         53 my @res;
169 26         42 my ($path_wc_re, $filename_wc_re);
170             L1:
171 26         66 for my $trash_dir (@trash_dirs) {
172             #next unless -d $trash_dir;
173             #next unless -d "$trash_dir/info";
174             opendir my($dh), "$trash_dir/info"
175 26 100       977 or do { warn "Can't read trash info dir $trash_dir/info: $!"; next };
  1         97  
  1         10  
176             ENTRY:
177 25         556 for my $e (readdir $dh) {
178 88 100       477 next unless $e =~ /\.trashinfo$/;
179 38         159 local $/;
180 38         95 my $ifile = "$trash_dir/info/$e";
181 38 50       1425 open my($fh), "<", $ifile
182             or die "Can't open trash info file $e: $!";
183 38         927 my $content = <$fh>;
184 38         390 close $fh;
185 38         153 my $parse_res = $self->_parse_trashinfo($content);
186 38 50       98 die "Can't parse trash info file $e: $parse_res" unless ref($parse_res);
187              
188             FILTER: {
189 38 100       53 if (defined $opts->{path}) {
  38         98  
190 2 100       14 next ENTRY unless $parse_res->{path} eq $opts->{path};
191             }
192 37 100       92 if (defined $opts->{path_wildcard}) {
193 2 100       8 unless (defined $path_wc_re) {
194 1         513 require String::Wildcard::Bash;
195 1         1943 $path_wc_re = String::Wildcard::Bash::convert_wildcard_to_re({globstar=>1}, $opts->{path_wildcard});
196             }
197 2 100       201 next ENTRY unless $parse_res->{path} =~ $path_wc_re;
198             }
199 36 100       66 if (defined $opts->{path_re}) {
200 2 100       20 next ENTRY unless $parse_res->{path} =~ $opts->{path_re};
201             }
202             FILTER_FILENAME: {
203 35         53 (my $filename = $parse_res->{path}) =~ s!.+/!!;
  35         223  
204 35 100       92 if (defined $opts->{filename}) {
205 21 100       95 next ENTRY unless $filename eq $opts->{filename};
206             }
207 32 100       67 if (defined $opts->{filename_wildcard}) {
208 4 100       11 unless (defined $filename_wc_re) {
209 2         12 require String::Wildcard::Bash;
210 2         11 $filename_wc_re = String::Wildcard::Bash::convert_wildcard_to_re({globstar=>1}, $opts->{filename_wildcard});
211             }
212 4 100       209 next ENTRY unless $filename =~ $filename_wc_re;
213             }
214 31 100       69 if (defined $opts->{filename_re}) {
215 4 100       50 next ENTRY unless $filename =~ $opts->{filename_re};
216             }
217             } # FILTER_FILENAME
218             } # FILTER
219              
220 30         89 my $afile = "$trash_dir/files/$e"; $afile =~ s/\.trashinfo\z//;
  30         121  
221 30 100       78 if (defined $opts->{mtime}) {
222 3         447 my @st = lstat($afile);
223 3 100 66     72 next ENTRY unless !@st || $st[9] == $opts->{mtime};
224             }
225 29 100       58 if (defined $opts->{suffix}) {
226 5 100       73 next ENTRY unless $afile =~ /\.\Q$opts->{suffix}\E\z/;
227             }
228 27         56 $parse_res->{trash_dir} = $trash_dir;
229 27         74 $e =~ s/\.trashinfo//; $parse_res->{entry} = $e;
  27         60  
230 27         282 push @res, $parse_res;
231             }
232             }
233              
234             @res = sort {
235 26         112 $a->{deletion_date} <=> $b->{deletion_date} ||
236             $a->{entry} cmp $b->{entry}
237 4 0       29 } @res;
238              
239 26         113 @res;
240             }
241              
242             sub trash {
243 19     19 1 28879 my $self = shift;
244 19         33 my $opts;
245 19 100       60 if (ref($_[0]) eq 'HASH') {
246 3         13 $opts = shift;
247             } else {
248 16         33 $opts = {};
249             }
250 19   100     128 $opts->{on_not_found} //= 'die';
251 19         83 my ($file0) = @_;
252              
253 19 100       91 unless (file_exists $file0) {
254 2 100       44 if ($opts->{on_not_found} eq 'ignore') {
255 1         12 return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
256             } else {
257 1         17 die "File does not exist: $file0";
258             }
259             }
260 17         375 my $afile = l_abs_path($file0);
261 17         541 my $trash_dir = $self->_select_trash($afile);
262              
263             # try to create info/NAME first
264 17 50       39 my $name0 = $afile; $name0 =~ s!.*/!!; $name0 = "WTF" unless length($name0);
  17         117  
  17         53  
265 17         35 my $name;
266             my $fh;
267 17 100       27 my $i = 1; my $limit = defined($opts->{suffix}) ? 1 : 1000;
  17         49  
268 17         22 my $tinfo;
269 17         27 while (1) {
270 19 100       80 $name = $name0 . (defined($opts->{suffix}) ? ".$opts->{suffix}" :
    100          
271             ($i > 1 ? ".$i" : ""));
272 19         76 $tinfo = "$trash_dir/info/$name.trashinfo";
273 19 100       1306 last if sysopen($fh, $tinfo, O_WRONLY | O_EXCL | O_CREAT);
274 2 50       18 die "Can't create trash info file $name.trashinfo in $trash_dir: $!"
275             if $i >= $limit;
276 2         5 $i++;
277             }
278 17         102 my $tfile = "$trash_dir/files/$name";
279              
280 17         449 my @t = localtime();
281 17         142 my $ts = sprintf("%04d%02d%02dT%02d:%02d:%02d",
282             $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
283 17         519 syswrite($fh, "[Trash Info]\nPath=$afile\nDeletionDate=$ts\n");
284 17 50       226 close $fh or die "Can't write trash info for $name in $trash_dir: $!";
285              
286 17         94 log_trace("Trashing %s -> %s ...", $afile, $tfile);
287 17 50       793 unless (rename($afile, $tfile)) {
288 0         0 unlink "$trash_dir/info/$name.trashinfo";
289 0         0 die "Can't rename $afile to $tfile: $!";
290             }
291              
292 17         152 $tfile;
293             }
294              
295             sub recover {
296 13     13 1 11079 my $self = shift;
297 13         23 my $opts;
298 13 100       41 if (ref($_[0]) eq 'HASH') {
299 7         14 $opts = shift;
300             } else {
301 6         28 $opts = {};
302             }
303 13   50     81 $opts->{on_not_found} //= 'die';
304 13   100     67 $opts->{on_target_exists} //= 'die';
305 13         61 my ($file0, $trash_dir) = @_;
306              
307 13   66     70 $opts->{filename} //= $file0;
308 13         41 my @ct = $self->list_contents($opts, $trash_dir);
309              
310             ENTRY:
311 13         51 for my $e (@ct) {
312 15 100       61 if (file_exists($e->{path})) {
313 3 100       74 if ($opts->{on_target_exists} eq 'ignore') {
314 1         9 next ENTRY;
315             } else {
316 2         31 die "Restore target already exists: $e->{path}";
317             }
318             }
319 12         335 my $afile = l_abs_path($e->{path});
320 12         418 my $ifile = "$e->{trash_dir}/info/$e->{entry}.trashinfo";
321 12         28 my $tfile = "$e->{trash_dir}/files/$e->{entry}";
322 12         42 log_trace("Recovering from trash %s -> %s ...", $tfile, $afile);
323 12 50       383 unless (rename($tfile, $afile)) {
324 0         0 die "Can't rename $tfile to $afile: $!";
325             }
326 12         620 unlink($ifile);
327             }
328             }
329              
330             sub _erase {
331 5     5   599 require File::Remove;
332              
333 5         2222 my ($self, $opts, $trash_dir) = @_;
334              
335 5         16 my @ct = $self->list_contents($opts, $trash_dir);
336 5         11 my @res;
337 5         13 for my $e (@ct) {
338 4         13 my $f = "$e->{trash_dir}/info/$e->{entry}.trashinfo";
339 4 50       185 unlink $f or die "Can't remove $f: $!";
340             # XXX File::Remove interprets wildcard, what if filename contains
341             # wildcard?
342 4         38 File::Remove::remove(\1, "$e->{trash_dir}/files/$e->{entry}");
343 4         5367 push @res, $e->{path};
344             }
345 5         48 @res;
346             }
347              
348             sub erase {
349 2     2 1 2685 my $self = shift;
350 2 100       10 my $opts = ref($_[0]) eq 'HASH' ? {%{shift(@_)}} : {};
  1         7  
351 2         6 my ($file, $trash_dir) = @_;
352 2   66     14 $opts->{filename} //= $file;
353              
354             # make sure user specifies at least one of filename
355             # option/$file/filename_wildcard/filename_re/path/path_wildcard/path_re.
356             # specifying no files will include all entries. for that user should be more
357             # explicit and call empty().
358 2 0 66     13 unless (defined $file or
      66        
      33        
      33        
      0        
      0        
359             defined $opts->{filename} or
360             defined $opts->{filename_wildcard} or
361             defined $opts->{filename_re} or
362             defined $opts->{path} or
363             defined $opts->{path_wildcard} or
364             defined $opts->{path_re}) {
365 0         0 die "Please specify at least file/filename/filename_wildcard/filename_re ".
366             "or path/path_wildcard/path_re";
367             }
368 2         9 $self->_erase($opts, $trash_dir);
369             }
370              
371             sub empty {
372 3     3 1 1526 my ($self, $trash_dir) = @_;
373              
374 3         58 $self->_erase({}, $trash_dir);
375             }
376              
377             1;
378             # ABSTRACT: Trash files
379              
380             __END__