File Coverage

blib/lib/File/Trash/FreeDesktop.pm
Criterion Covered Total %
statement 181 202 89.6
branch 74 108 68.5
condition 10 12 83.3
subroutine 19 19 100.0
pod 7 7 100.0
total 291 348 83.6


line stmt bran cond sub pod time code
1             package File::Trash::FreeDesktop;
2              
3 2     2   171456 use 5.010001;
  2         23  
4 2     2   9 use strict;
  2         4  
  2         31  
5 2     2   8 use warnings;
  2         4  
  2         49  
6 2     2   2816 use Log::ger;
  2         82  
  2         8  
7              
8 2     2   434 use Fcntl;
  2         5  
  2         320  
9 2     2   11 use File::MoreUtil qw(file_exists l_abs_path);
  2         3  
  2         4350  
10              
11             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
12             our $DATE = '2022-05-06'; # DATE
13             our $DIST = 'File-Trash-FreeDesktop'; # DIST
14             our $VERSION = '0.200'; # VERSION
15              
16             sub new {
17 2     2 1 2060 require File::HomeDir::FreeDesktop;
18              
19 2         6426 my ($class, %opts) = @_;
20              
21 2 50       18 my $home = File::HomeDir::FreeDesktop->my_home
22             or die "Can't get homedir, ".
23             "probably not a freedesktop-compliant environment?";
24 2         60 $opts{_home} = l_abs_path($home);
25              
26 2         55 bless \%opts, $class;
27             }
28              
29             sub _mk_trash {
30 10     10   28 my ($self, $trash_dir) = @_;
31 10         25 for ("", "/files", "/info") {
32 30         69 my $d = "$trash_dir$_";
33 30 100       280 unless (-d $d) {
34 3         11 log_trace("Creating directory %s ...", $d);
35 3 50       122 mkdir $d, 0700 or die "Can't mkdir $d: $!";
36             }
37             }
38             }
39              
40             sub _home_trash {
41 22     22   557 my ($self) = @_;
42 22         112 "$self->{_home}/.local/share/Trash";
43             }
44              
45             sub _mk_home_trash {
46 10     10   25 my ($self) = @_;
47 10         28 for (".local", ".local/share") {
48 20         54 my $d = "$self->{_home}/$_";
49 20 100       237 unless (-d $d) {
50 2 50       139 mkdir $d or die "Can't mkdir $d: $!";
51             }
52             }
53 10         61 $self->_mk_trash("$self->{_home}/.local/share/Trash");
54             }
55              
56             sub _select_trash {
57 10     10   490 require Sys::Filesystem::MountPoint;
58              
59 10         21790 my ($self, $file0) = @_;
60 10 50       29 file_exists($file0) or die "File doesn't exist: $file0";
61 10         170 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 10 100       311 my $afile2 = $afile; $afile2 =~ s!/[^/]+\z!! if (-l $file0);
  10         97  
67 10         62 my $file_mp = Sys::Filesystem::MountPoint::path_to_mount_point($afile2);
68              
69 10 50       6562 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 10   66     44 $self->{_home});
75              
76 10 50       289 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 10 50       28 if ($self->{_home_mp} eq $file_mp) {
82 10         33 my $trash_dir = $self->_home_trash;
83 10         55 log_trace("Selected home trash for %s = %s", $afile, $trash_dir);
84 10         55 $self->_mk_home_trash;
85 10         38 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 11     11 1 106 require List::Util;
114 11         486 require Sys::Filesystem;
115              
116 11         19961 my ($self) = @_;
117              
118 11         123 my $sysfs = Sys::Filesystem->new;
119 11         21205 my @mp = $sysfs->filesystems;
120              
121 11         100 my @res = map { l_abs_path($_) }
122 1639         13736 grep {-d} (
123             $self->_home_trash,
124 11         713 (map { (
125 407         4886 "$_/.Trash-$>",
126             "$_/tmp/.Trash-$>",
127             "$_/.Trash/$>",
128             "$_/tmp/.Trash/$>",
129             ) } @mp)
130             );
131              
132 11         1411 List::Util::uniq(@res);
133             }
134              
135             sub _parse_trashinfo {
136 15     15   754 require Time::Local;
137              
138 15         1643 my ($self, $content) = @_;
139 15 50       87 $content =~ /\A\[Trash Info\]/m or return "No header line";
140 15         49 my $res = {};
141 15 50       109 $content =~ /^Path=(.+)/m or return "No Path line";
142 15         87 $res->{path} = $1;
143 15 50       107 $content =~ /^DeletionDate=(\d{4})-?(\d{2})-?(\d{2})T(\d\d):(\d\d):(\d\d)$/m
144             or return "No/invalid DeletionDate line";
145 15 50       155 $res->{deletion_date} = Time::Local::timelocal(
146             $6, $5, $4, $3, $2-1, $1-1900)
147             or return "Invalid date: $1-$2-$3T$4-$5-$6";
148 15         1460 $res;
149             }
150              
151             sub list_contents {
152 16     16 1 1390 my $self = shift;
153              
154 16         25 my $opts;
155 16 100       59 if (ref($_[0]) eq 'HASH') {
156 15         27 $opts = shift;
157             } else {
158 1         2 $opts = {};
159             }
160 16         35 my ($trash_dir0) = @_;
161              
162 16 100       96 my @trash_dirs = $trash_dir0 ? ($trash_dir0) : ($self->list_trashes);
163 16         42 my @res;
164             L1:
165 16         56 for my $trash_dir (@trash_dirs) {
166             #next unless -d $trash_dir;
167             #next unless -d "$trash_dir/info";
168             opendir my($dh), "$trash_dir/info"
169 16 100       864 or do { warn "Can't read trash info dir $trash_dir/info: $!";next };
  1         58  
  1         8  
170 15         584 for my $e (readdir $dh) {
171 36 100       280 next unless $e =~ /\.trashinfo$/;
172 15         87 local $/;
173 15         54 my $ifile = "$trash_dir/info/$e";
174 15 50       653 open my($fh), "<", $ifile
175             or die "Can't open trash info file $e: $!";
176 15         482 my $content = <$fh>;
177 15         190 close $fh;
178 15         89 my $pres = $self->_parse_trashinfo($content);
179 15 50       56 die "Can't parse trash info file $e: $pres" unless ref($pres);
180 15 100       50 if (defined $opts->{search_path}) {
181 14 100       60 next unless $pres->{path} eq $opts->{search_path};
182             }
183 14         56 my $afile = "$trash_dir/files/$e"; $afile =~ s/\.trashinfo\z//;
  14         94  
184 14 100       46 if (defined $opts->{mtime}) {
185 4         64 my @st = lstat($afile);
186 4 100 66     63 next unless !@st || $st[9] == $opts->{mtime};
187             }
188 12 100       36 if (defined $opts->{suffix}) {
189 5 100       145 next unless $afile =~ /\.\Q$opts->{suffix}\E\z/;
190             }
191 9         26 $pres->{trash_dir} = $trash_dir;
192 9         38 $e =~ s/\.trashinfo//; $pres->{entry} = $e;
  9         26  
193 9         32 push @res, $pres;
194 9 100       182 last L1 if defined $opts->{search_path};
195             }
196             }
197              
198             @res = sort {
199 16         93 $a->{deletion_date} <=> $b->{deletion_date} ||
200             $a->{entry} cmp $b->{entry}
201 0 0       0 } @res;
202              
203 16         67 @res;
204             }
205              
206             sub trash {
207 12     12 1 22508 my $self = shift;
208 12         27 my $opts;
209 12 100       54 if (ref($_[0]) eq 'HASH') {
210 3         12 $opts = shift;
211             } else {
212 9         23 $opts = {};
213             }
214 12   100     100 $opts->{on_not_found} //= 'die';
215 12         50 my ($file0) = @_;
216              
217 12 100       70 unless (file_exists $file0) {
218 2 100       158 if ($opts->{on_not_found} eq 'ignore') {
219 1         6 return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
220             } else {
221 1         20 die "File does not exist: $file0";
222             }
223             }
224 10         250 my $afile = l_abs_path($file0);
225 10         301 my $trash_dir = $self->_select_trash($afile);
226              
227             # try to create info/NAME first
228 10 50       24 my $name0 = $afile; $name0 =~ s!.*/!!; $name0 = "WTF" unless length($name0);
  10         90  
  10         35  
229 10         16 my $name;
230             my $fh;
231 10 100       16 my $i = 1; my $limit = defined($opts->{suffix}) ? 1 : 1000;
  10         31  
232 10         13 my $tinfo;
233 10         16 while (1) {
234 12 100       50 $name = $name0 . (defined($opts->{suffix}) ? ".$opts->{suffix}" :
    100          
235             ($i > 1 ? ".$i" : ""));
236 12         28 $tinfo = "$trash_dir/info/$name.trashinfo";
237 12 100       853 last if sysopen($fh, $tinfo, O_WRONLY | O_EXCL | O_CREAT);
238 2 50       12 die "Can't create trash info file $name.trashinfo in $trash_dir: $!"
239             if $i >= $limit;
240 2         5 $i++;
241             }
242 10         60 my $tfile = "$trash_dir/files/$name";
243              
244 10         316 my @t = localtime();
245 10         114 my $ts = sprintf("%04d%02d%02dT%02d:%02d:%02d",
246             $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
247 10         384 syswrite($fh, "[Trash Info]\nPath=$afile\nDeletionDate=$ts\n");
248 10 50       138 close $fh or die "Can't write trash info for $name in $trash_dir: $!";
249              
250 10         48 log_trace("Trashing %s -> %s ...", $afile, $tfile);
251 10 50       400 unless (rename($afile, $tfile)) {
252 0         0 unlink "$trash_dir/info/$name.trashinfo";
253 0         0 die "Can't rename $afile to $tfile: $!";
254             }
255              
256 10         107 $tfile;
257             }
258              
259             sub recover {
260 14     14 1 14302 my $self = shift;
261 14         27 my $opts;
262 14 100       61 if (ref($_[0]) eq 'HASH') {
263 9         18 $opts = shift;
264             } else {
265 5         12 $opts = {};
266             }
267 14   100     98 $opts->{on_not_found} //= 'die';
268 14   100     73 $opts->{on_target_exists} //= 'die';
269 14         41 my ($file0, $trash_dir0) = @_;
270              
271 14 100       52 if (file_exists($file0)) {
272 3 100       69 if ($opts->{on_target_exists} eq 'ignore') {
273 1         5 return 0;
274             } else {
275 2         32 die "Restore target already exists: $file0";
276             }
277             }
278 11         256 my $afile = l_abs_path($file0);
279              
280             my @res = $self->list_contents({
281             search_path => $afile,
282             mtime => $opts->{mtime},
283             suffix => $opts->{suffix},
284 11         311 }, $trash_dir0);
285 11 100       56 unless (@res) {
286 4 100       23 if ($opts->{on_not_found} eq 'ignore') {
287 1         13 return 0;
288             } else {
289 3         78 die "File not found in trash: $file0";
290             }
291             }
292              
293 7         23 my $trash_dir = $res[0]{trash_dir};
294 7         31 my $ifile = "$trash_dir/info/$res[0]{entry}.trashinfo";
295 7         21 my $tfile = "$trash_dir/files/$res[0]{entry}";
296 7         42 log_trace("Recovering from trash %s -> %s ...", $tfile, $afile);
297 7 50       423 unless (rename($tfile, $afile)) {
298 0         0 die "Can't rename $tfile to $afile: $!";
299             }
300 7         588 unlink($ifile);
301             }
302              
303             sub _erase {
304 4     4   704 require File::Remove;
305              
306 4         2029 my ($self, $file0, $trash_dir) = @_;
307 4 100       23 my $afile = defined($file0) ? l_abs_path($file0) : undef;
308              
309 4         73 my @ct = $self->list_contents({search_path=>$afile}, $trash_dir);
310              
311 4         9 my @res;
312 4         12 for (@ct) {
313 2         11 my $f = "$_->{trash_dir}/info/$_->{entry}.trashinfo";
314 2 50       160 unlink $f or die "Can't remove $f: $!";
315             # XXX File::Remove interprets wildcard, what if filename contains
316             # wildcard?
317 2         20 File::Remove::remove(\1, "$_->{trash_dir}/files/$_->{entry}");
318 2         3857 push @res, $_->{path};
319             }
320 4         44 @res;
321             }
322              
323             sub erase {
324 1     1 1 2587 my ($self, $file, $trash_dir) = @_;
325              
326 1 50       5 die "Please specify file" unless defined $file;
327 1         5 $self->_erase($file, $trash_dir);
328             }
329              
330             # XXX currently empty calls _erase, which parses .trashinfo files. this is
331             # useless overhead.
332             sub empty {
333 3     3 1 2109 my ($self, $trash_dir) = @_;
334              
335 3         15 $self->_erase(undef, $trash_dir);
336             }
337              
338             1;
339             # ABSTRACT: Trash files
340              
341             __END__