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 3     3   826368 use 5.010001;
  3         12  
4 3     3   16 use strict;
  3         6  
  3         83  
5 3     3   15 use warnings;
  3         4  
  3         213  
6 3     3   6152 use Log::ger;
  3         195  
  3         22  
7              
8 3     3   999 use Fcntl;
  3         6  
  3         949  
9 3     3   2104 use File::Util::Test qw(file_exists l_abs_path);
  3         17389  
  3         13686  
10              
11             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
12             our $DATE = '2023-11-21'; # DATE
13             our $DIST = 'File-Trash-FreeDesktop'; # DIST
14             our $VERSION = '0.207'; # VERSION
15              
16             sub new {
17 2     2 1 839213 require File::HomeDir::FreeDesktop;
18              
19 2         12519 my ($class, %opts) = @_;
20              
21 2 50       22 my $home = File::HomeDir::FreeDesktop->my_home
22             or die "Can't get homedir, ".
23             "probably not a freedesktop-compliant environment?";
24 2         114 $opts{_home} = l_abs_path($home);
25              
26 2         84 bless \%opts, $class;
27             }
28              
29             sub _mk_trash {
30 17     17   52 my ($self, $trash_dir) = @_;
31 17         47 for ("", "/files", "/info") {
32 51         109 my $d = "$trash_dir$_";
33 51 100       769 unless (-d $d) {
34 3         20 log_trace("Creating directory %s ...", $d);
35 3 50       410 mkdir $d, 0700 or die "Can't mkdir $d: $!";
36             }
37             }
38             }
39              
40             sub _home_trash {
41 39     39   1426 my ($self) = @_;
42 39         243 "$self->{_home}/.local/share/Trash";
43             }
44              
45             sub _mk_home_trash {
46 17     17   38 my ($self) = @_;
47 17         44 for (".local", ".local/share") {
48 34         90 my $d = "$self->{_home}/$_";
49 34 100       687 unless (-d $d) {
50 2 50       291 mkdir $d or die "Can't mkdir $d: $!";
51             }
52             }
53 17         179 $self->_mk_trash("$self->{_home}/.local/share/Trash");
54             }
55              
56             sub _select_trash {
57 17     17   905 require Sys::Filesystem::MountPoint;
58              
59 17         40561 my ($self, $file0) = @_;
60 17 50       75 file_exists($file0) or die "File doesn't exist: $file0";
61 17         396 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       696 my $afile2 = $afile; $afile2 =~ s!/[^/]+\z!! if (-l $file0);
  17         246  
67 17         138 my $file_mp = Sys::Filesystem::MountPoint::path_to_mount_point($afile2);
68              
69 17 50       12676 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     106 $self->{_home});
75              
76 17 50       390 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       53 if ($self->{_home_mp} eq $file_mp) {
82 17         101 my $trash_dir = $self->_home_trash;
83 17         107 log_trace("Selected home trash for %s = %s", $afile, $trash_dir);
84 17         76 $self->_mk_home_trash;
85 17         77 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 158 require List::Util;
114 21         481 require Sys::Filesystem;
115              
116 21         22935 my ($self) = @_;
117              
118 21         167 my $sysfs = Sys::Filesystem->new;
119 21         87165 my @mp = $sysfs->filesystems;
120              
121 21         151 my @res = map { l_abs_path($_) }
122 117         28446 grep {-d} (
123             $self->_home_trash,
124             (
125 21 100       1124 $self->{home_only} ? () : (map { (
126 24         278 "$_/.Trash-$>",
127             "$_/tmp/.Trash-$>",
128             "$_/.Trash/$>",
129             "$_/tmp/.Trash/$>",
130             ) } @mp)
131             )
132             );
133              
134 21         3820 List::Util::uniq(@res);
135             }
136              
137             sub _parse_trashinfo {
138 41     41   934 require Time::Local;
139              
140             # we use regex parsing instead of INI to be simpler
141 41         2709 my ($self, $content) = @_;
142 41 50       233 $content =~ /\A\[Trash Info\]/m or return "No header line";
143 41         82 my $res = {};
144 41 50       236 $content =~ /^Path=(.+)/m or return "No Path line";
145 41         294 $res->{path} = $1;
146             PARSE_DELETIONDATE: {
147 41         77 $content =~ /^DeletionDate=(\d{4})-?(\d{2})-?(\d{2})T(\d\d):(\d\d):(\d\d)$/m
148 41 50       266 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 41 50       416 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 41         4215 $res;
154             }
155              
156             sub list_contents {
157 26     26 1 20240 my $self = shift;
158              
159 26         46 my $opts;
160 26 100       96 if (ref($_[0]) eq 'HASH') {
161 24         51 $opts = shift;
162             } else {
163 2         5 $opts = {};
164             }
165 26         55 my ($trash_dir0) = @_;
166              
167 26 100       103 my @trash_dirs = $trash_dir0 ? ($trash_dir0) : ($self->list_trashes);
168 26         68 my @res;
169 26         60 my ($path_wc_re, $filename_wc_re);
170             L1:
171 26         79 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       1604 or do { warn "Can't read trash info dir $trash_dir/info: $!"; next };
  1         73  
  1         10  
176             ENTRY:
177 25         4327 for my $e (readdir $dh) {
178 91 100       427 next unless $e =~ /\.trashinfo$/;
179 41         198 local $/;
180 41         122 my $ifile = "$trash_dir/info/$e";
181 41 50       2335 open my($fh), "<", $ifile
182             or die "Can't open trash info file $e: $!";
183 41         1906 my $content = <$fh>;
184 41         575 close $fh;
185 41         343 my $parse_res = $self->_parse_trashinfo($content);
186 41 50       115 die "Can't parse trash info file $e: $parse_res" unless ref($parse_res);
187              
188             FILTER: {
189 41 100       63 if (defined $opts->{path}) {
  41         131  
190 12 100       126 next ENTRY unless $parse_res->{path} eq $opts->{path};
191             }
192 37 100       94 if (defined $opts->{path_wildcard}) {
193 2 100       8 unless (defined $path_wc_re) {
194 1         957 require String::Wildcard::Bash;
195 1         2683 $path_wc_re = String::Wildcard::Bash::convert_wildcard_to_re({globstar=>1}, $opts->{path_wildcard});
196             }
197 2 100       208 next ENTRY unless $parse_res->{path} =~ $path_wc_re;
198             }
199 36 100       86 if (defined $opts->{path_re}) {
200 2 100       45 next ENTRY unless $parse_res->{path} =~ $opts->{path_re};
201             }
202             FILTER_FILENAME: {
203 35         54 (my $filename = $parse_res->{path}) =~ s!.+/!!;
  35         288  
204 35 100       106 if (defined $opts->{filename}) {
205 12 100       58 next ENTRY unless $filename eq $opts->{filename};
206             }
207 33 100       161 if (defined $opts->{filename_wildcard}) {
208 5 100       15 unless (defined $filename_wc_re) {
209 2         14 require String::Wildcard::Bash;
210 2         32 $filename_wc_re = String::Wildcard::Bash::convert_wildcard_to_re({globstar=>1}, $opts->{filename_wildcard});
211             }
212 5 100       405 next ENTRY unless $filename =~ $filename_wc_re;
213             }
214 31 100       88 if (defined $opts->{filename_re}) {
215 4 100       60 next ENTRY unless $filename =~ $opts->{filename_re};
216             }
217             } # FILTER_FILENAME
218             } # FILTER
219              
220 30         72 my $afile = "$trash_dir/files/$e"; $afile =~ s/\.trashinfo\z//;
  30         154  
221 30 100       82 if (defined $opts->{mtime}) {
222 3         87 my @st = lstat($afile);
223 3 100 66     47 next ENTRY unless !@st || $st[9] == $opts->{mtime};
224             }
225 29 100       72 if (defined $opts->{suffix}) {
226 5 100       111 next ENTRY unless $afile =~ /\.\Q$opts->{suffix}\E\z/;
227             }
228 27         77 $parse_res->{trash_dir} = $trash_dir;
229 27         87 $e =~ s/\.trashinfo//; $parse_res->{entry} = $e;
  27         79  
230 27         613 push @res, $parse_res;
231             }
232             }
233              
234             @res = sort {
235 26         120 $a->{deletion_date} <=> $b->{deletion_date} ||
236             $a->{entry} cmp $b->{entry}
237 4 0       59 } @res;
238              
239 26         170 @res;
240             }
241              
242             sub trash {
243 19     19 1 55410 my $self = shift;
244 19         36 my $opts;
245 19 100       75 if (ref($_[0]) eq 'HASH') {
246 3         6 $opts = shift;
247             } else {
248 16         30 $opts = {};
249             }
250 19   100     168 $opts->{on_not_found} //= 'die';
251 19         69 my ($file0) = @_;
252              
253 19 100       89 unless (file_exists $file0) {
254 2 100       62 if ($opts->{on_not_found} eq 'ignore') {
255 1         13 return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
256             } else {
257 1         22 die "File does not exist: $file0";
258             }
259             }
260 17         548 my $afile = l_abs_path($file0);
261 17         641 my $trash_dir = $self->_select_trash($afile);
262              
263             # try to create info/NAME first
264 17 50       53 my $name0 = $afile; $name0 =~ s!.*/!!; $name0 = "WTF" unless length($name0);
  17         128  
  17         49  
265 17         53 my $name;
266             my $fh;
267 17 100       32 my $i = 1; my $limit = defined($opts->{suffix}) ? 1 : 1000;
  17         53  
268 17         56 my $tinfo;
269 17         24 while (1) {
270 19 100       71 $name = $name0 . (defined($opts->{suffix}) ? ".$opts->{suffix}" :
    100          
271             ($i > 1 ? ".$i" : ""));
272 19         38 $tinfo = "$trash_dir/info/$name.trashinfo";
273 19 100       3080 last if sysopen($fh, $tinfo, O_WRONLY | O_EXCL | O_CREAT);
274 2 50       14 die "Can't create trash info file $name.trashinfo in $trash_dir: $!"
275             if $i >= $limit;
276 2         4 $i++;
277             }
278 17         96 my $tfile = "$trash_dir/files/$name";
279              
280 17         409 my @t = localtime();
281 17         149 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         673 syswrite($fh, "[Trash Info]\nPath=$afile\nDeletionDate=$ts\n");
284 17 50       307 close $fh or die "Can't write trash info for $name in $trash_dir: $!";
285              
286 17         150 log_trace("Trashing %s -> %s ...", $afile, $tfile);
287 17 50       3204 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         303 $tfile;
293             }
294              
295             sub recover {
296 13     13 1 20849 my $self = shift;
297 13         28 my $opts;
298 13 100       54 if (ref($_[0]) eq 'HASH') {
299 7         22 $opts = shift;
300             } else {
301 6         14 $opts = {};
302             }
303 13   50     119 $opts->{on_not_found} //= 'die';
304 13   100     90 $opts->{on_target_exists} //= 'die';
305 13         65 my ($file0, $trash_dir) = @_;
306              
307 13   66     72 $opts->{path} //= $file0;
308 13         51 my @ct = $self->list_contents($opts, $trash_dir);
309              
310             ENTRY:
311 13         35 for my $e (@ct) {
312 14 100       73 if (file_exists($e->{path})) {
313 3 100       95 if ($opts->{on_target_exists} eq 'ignore') {
314 1         14 next ENTRY;
315             } else {
316 2         40 die "Restore target already exists: $e->{path}";
317             }
318             }
319 11         506 my $afile = l_abs_path($e->{path});
320 11         477 my $ifile = "$e->{trash_dir}/info/$e->{entry}.trashinfo";
321 11         26 my $tfile = "$e->{trash_dir}/files/$e->{entry}";
322 11         71 log_trace("Recovering from trash %s -> %s ...", $tfile, $afile);
323 11 50       1408 unless (rename($tfile, $afile)) {
324 0         0 die "Can't rename $tfile to $afile: $!";
325             }
326 11         1513 unlink($ifile);
327             }
328             }
329              
330             sub _erase {
331 5     5   756 require File::Remove;
332              
333 5         2896 my ($self, $opts, $trash_dir) = @_;
334              
335 5         63 my @ct = $self->list_contents($opts, $trash_dir);
336 5         9 my @res;
337 5         13 for my $e (@ct) {
338 5         18 my $f = "$e->{trash_dir}/info/$e->{entry}.trashinfo";
339 5 50       707 unlink $f or die "Can't remove $f: $!";
340             # XXX File::Remove interprets wildcard, what if filename contains
341             # wildcard?
342 5         140 File::Remove::remove(\1, "$e->{trash_dir}/files/$e->{entry}");
343 5         10179 push @res, $e->{path};
344             }
345 5         75 @res;
346             }
347              
348             sub erase {
349 2     2 1 5850 my $self = shift;
350 2 100       11 my $opts = ref($_[0]) eq 'HASH' ? {%{shift(@_)}} : {};
  1         6  
351 2         6 my ($file, $trash_dir) = @_;
352 2   66     15 $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     17 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         31 $self->_erase($opts, $trash_dir);
369             }
370              
371             sub empty {
372 3     3 1 2015 my ($self, $trash_dir) = @_;
373              
374 3         15 $self->_erase({}, $trash_dir);
375             }
376              
377             1;
378             # ABSTRACT: Trash files
379              
380             __END__