File Coverage

blib/lib/Sys/Export/Unix/WriteFS.pm
Criterion Covered Total %
statement 144 219 65.7
branch 51 130 39.2
condition 32 118 27.1
subroutine 21 29 72.4
pod 7 7 100.0
total 255 503 50.7


line stmt bran cond sub pod time code
1             package Sys::Export::Unix::WriteFS;
2              
3             # ABSTRACT: An export target that writes files to a directory in the host filesystem
4             our $VERSION = '0.005'; # VERSION
5              
6              
7 5     5   75 use v5.26;
  5         38  
8 5     5   22 use warnings;
  5         7  
  5         354  
9 5     5   32 use experimental qw( signatures );
  5         7  
  5         39  
10 5     5   765 use Carp qw( croak carp );
  5         7  
  5         353  
11 5     5   21 use Scalar::Util qw( blessed );
  5         6  
  5         344  
12             our @CARP_NOT= qw( Sys::Export::Unix );
13 5     5   27 use Cwd qw( abs_path );
  5         12  
  5         288  
14 5     5   21 use Sys::Export qw( :stat_modes :stat_tests isa_hash );
  5         6  
  5         45  
15             require Sys::Export::Unix;
16              
17             sub new {
18 6     6 1 11 my $class= shift;
19 6 50 33     52 my %attrs= @_ == 1 && isa_hash $_[0]? %{$_[0]}
  0 50       0  
20             : !(@_ & 1)? @_
21             : croak "Expected hashref or even-length list";
22              
23 6 50       16 defined $attrs{dst} or croak "Require 'dst' attribute";
24 6 50       24 my $dst_abs= abs_path($attrs{dst} =~ s,(?<=[^/])$,/,r)
25             or croak "dst directory '$attrs{dst}' does not exist";
26 6 50       229 length $dst_abs > 1
27             or croak "cowardly refusing to export to '$dst_abs'";
28 6         14 $attrs{dst_abs}= "$dst_abs/";
29              
30 6   33     22 $attrs{tmp} //= do {
31 6         31 my $tmp= File::Temp->newdir;
32             # Make sure can rename() from this $tmp to $dst
33 6         2152 my ($tmp_dev)= stat "$tmp/";
34 6         96 my ($dst_dev)= stat $attrs{dst};
35             $tmp= File::Temp->newdir(DIR => $attrs{dst_abs})
36 6 50       71 if $tmp_dev != $dst_dev;
37 6         24 $tmp;
38             };
39              
40 6         12 my $self= bless \%attrs, $class;
41              
42 6         23 return $self;
43             }
44              
45              
46 0     0 1 0 sub dst($self) { $self->{dst} }
  0         0  
  0         0  
  0         0  
47 73     73 1 79 sub dst_abs($self) { $self->{dst_abs} }
  73         75  
  73         65  
  73         481  
48 15     15 1 35 sub tmp($self) { $self->{tmp} }
  15         22  
  15         14  
  15         68  
49              
50             sub on_collision {
51 0 0   0 1 0 return shift->_set_on_collision(@_) if @_ > 1;
52             shift->{on_collision}
53 0         0 }
54             my %_valid_on_collision= (
55             croak => 'croak',
56             ignore_if_same => 'croak',
57             ignore => 'ignore',
58             overwrite => 'overwrite',
59             );
60 0     0   0 sub _set_on_collision($self, $val) {
  0         0  
  0         0  
  0         0  
61             $self->{on_collision}= ref $val eq 'CODE'? $val
62 0 0 0     0 : $_valid_on_collision{$val}
63             // croak("Invalid 'on_collision' value '$val'");
64 0         0 $self;
65             }
66              
67             # a hashref tracking files with link-count higher than 1, so that hardlinks can be preserved.
68             # the keys are "$dev:$ino"
69 4   100 4   4 sub _link_map($self) { $self->{link_map} //= {} }
  4         14  
  4         15  
  4         24  
70              
71 6     6   14 sub DESTROY($self, @) {
  6         16  
  6         9  
72 6 100       39 $self->finish if $self->{_delayed_apply_stat};
73             }
74              
75              
76 21     21 1 23 sub add($self, $file) {
  21         21  
  21         20  
  21         19  
77             croak "Path must start with a name, not slash or dot: '$file->{name}'"
78 21 50       142 if $file->{name} =~ m{(^|^\.|^\.\.)(/|\z)};
79 21   33     36 my $mode= $file->{mode} // croak "attribute 'mode' is required, for '$file->{name}'";
80             # Does it already exist?
81 21         37 my $dst_abs= $self->dst_abs . $file->{name};
82 21         51 my %old= (name => $file->{name});
83 21 50       687 if (@old{qw( dev ino mode nlink uid gid rdev size atime mtime)}= lstat($dst_abs)) {
84 0   0     0 my $action= $self->on_collision // 'croak';
85             # Nothing to do if user wants collisions ignored
86 0 0       0 return !!0 if $action eq 'ignore';
87 0 0       0 if (defined(my $difference= $self->_compare_dirent($file, \%old))) {
88             # Call user callback, if any
89 0 0       0 $action= $action->($dst_abs, $file)
90             if ref $action eq 'CODE';
91             # validate its return value
92 0   0     0 $action= $_valid_on_collision{$action // ''}
      0        
93             // croak "Unknown on_collision action '$action'";
94 0 0       0 return !!0 if $action eq 'ignore';
95 0 0       0 croak $difference unless $action eq 'overwrite';
96             # overwrite, but directory might not be empty, so handle that later
97 0 0       0 unlink $dst_abs unless S_ISDIR($old{mode});
98             }
99             }
100             return S_ISREG($mode)? $self->_add_file($file)
101             : S_ISDIR($mode)? $self->_add_dir($file, (defined $old{mode}? \%old : undef))
102             : S_ISLNK($mode)? $self->_add_symlink($file)
103             : (S_ISBLK($mode) || S_ISCHR($mode))? $self->_add_devnode($file)
104             : S_ISFIFO($mode)? $self->_add_fifo($file)
105             : S_ISSOCK($mode)? $self->_add_socket($file)
106             : croak "Can't export ".(S_ISWHT($mode)? 'whiteout entries' : '(unknown)')
107 21 50 66     196 .': "'.($file->{src_path} // $file->{name}).'"'
    0 0        
    50          
    50          
    100          
    100          
    100          
    100          
108             }
109              
110             our %_mode_name= (
111             S_IFREG , 'file',
112             S_IFDIR , 'dir',
113             S_IFLNK , 'symlink',
114             S_IFBLK , 'block device',
115             S_IFCHR , 'char device',
116             S_IFIFO , 'fifo',
117             S_IFSOCK , 'socket',
118             (S_IFWHT? (
119             S_IFWHT , 'whiteout',
120             ):()),
121             );
122 0   0 0   0 sub _mode_name($mode) { $_mode_name{($mode & S_IFMT)} // '(unknown)' }
  0         0  
  0         0  
  0         0  
123              
124             # Compare two dirents and return error msg if new is not equivalent to old
125 0     0   0 sub _compare_dirent($self, $file, $old) {
  0         0  
  0         0  
  0         0  
  0         0  
126 0         0 my $dst_abs= $self->dst_abs . $file->{name};
127             return "Attempt to write "._mode_name($file->{mode})." overtop existing "._mode_name($old->{mode})." at $dst_abs"
128 0 0       0 if ($file->{mode} & S_IFMT) != ($old->{mode} & S_IFMT);
129             return "Attempt to write ownership $file->{uid}:$file->{gid} to $dst_abs which was previously $old->{uid}:$old->{gid}"
130 0 0 0     0 if (defined $file->{uid} && $file->{uid} != $old->{uid}) || (defined $file->{gid} && $file->{gid} != $old->{gid});
      0        
      0        
131             # For symlinks, compare only the content of the link. Permissions are ignored.
132 0 0       0 if (S_ISLNK($file->{mode})) {
133 0         0 my $targ= readlink $dst_abs;
134             return "Attempt to rewrite symlink $dst_abs from $targ to $file->{data}"
135 0 0       0 if $targ ne $file->{data};
136 0         0 return !!0;
137             }
138             # For everything else, compare permissions
139             return "Attempt to write permissions ".($file->{mode} & ~S_IFMT)." overtop existing ".($old->{mode} & ~S_IFMT)." at $dst_abs"
140 0 0       0 unless $file->{mode} == $old->{mode};
141              
142 0 0 0     0 if (S_ISREG($file->{mode})) {
    0          
143             # compare file contents
144             return "Attempt to overwrite $dst_abs with different content"
145             if (defined $file->{size} && $file->{size} != $old->{size})
146 0 0 0     0 || !_contents_same($file, $dst_abs);
      0        
147             }
148             elsif (S_ISBLK($file->{mode}) || S_ISCHR($file->{mode})) {
149             # compare major/minor numbers
150             return "Attempt to overwrite $dst_abs with different major/minor value"
151 0 0       0 if $file->{rdev} != $old->{rdev};
152             }
153 0         0 undef;
154             }
155              
156             # Compare file contents for equality
157 0     0   0 sub _contents_same($file, $dst_abs) {
  0         0  
  0         0  
  0         0  
158 0         0 my $dst_data= Sys::Export::map_or_load_file($dst_abs);
159 0         0 return ${$file->{data}} eq $$dst_data;
  0         0  
160             }
161              
162             # compare device nodes for equality
163 0     0   0 sub _rdev_same($file, $old) {
  0         0  
  0         0  
  0         0  
164             # old will always have 'rdev' defined because we ran lstat.
165             # file might not if the user specified rdev_major and rdev_minor
166 0 0 0     0 if (defined $file->{rdev}) {
    0          
167 0         0 return $file->{rdev} == $old->{rdev};
168             } elsif (defined $file->{rdev_major} && defined $file->{rdev_minor}) {
169 0         0 my ($maj, $min)= Sys::Export::Unix::_dev_major_minor($old->{rdev});
170 0   0     0 return $file->{rdev_major} == $maj && $file->{rdev_minor} == $min;
171             } else {
172 0         0 return !!0; # not defined, so can't be same
173             }
174             }
175              
176             # Install a file into ->dst
177 6     6   8 sub _add_file($self, $file) {
  6         8  
  6         5  
  6         6  
178 6         11 my $dst= $self->dst_abs . $file->{name};
179             # See if this is supposed to be a hardlink
180 6 100       36 if ($file->{nlink} > 1) {
181 1 50       3 if (defined(my $already= $self->_link_map->{"$file->{dev}:$file->{ino}"})) {
182             # Yep, make a link of that file instead of copying again
183 1 50       42 link($already, $dst)
184             or croak "link($already, $dst): $!";
185 1         11 return !!1;
186             }
187             }
188             # Record all file inodes in case a delayed hardlink is created by the caller
189             $self->_link_map->{"$file->{dev}:$file->{ino}"}= $dst
190 5 100 66     24 if defined $file->{dev} && defined $file->{ino};
191             # Write data into a temp file on same filesystem, then rename it into place
192             # to ensure we never write a partial file into the destination.
193             # But, check if the caller gave us a LazyFileData within our ->tmp directory.
194 5         7 my $tmp;
195 5 50 66     44 if (blessed $file->{data} && $file->{data}->can('abs_path')
      50        
      66        
196             && substr($file->{data}->abs_path//'', 0, length $self->tmp) eq $self->tmp
197             ) {
198 0         0 $tmp= $file->{data}->abs_path;
199             } else {
200 5         32 $tmp= File::Temp->new(DIR => $self->tmp, UNLINK => 0);
201 5         3997 Sys::Export::Unix::_syswrite_all($tmp, $file->{data});
202             }
203             # Apply matching permissions and ownership
204 5         100 $self->_apply_stat("$tmp", $file);
205             # Rename the temp file into place
206 5 50       15 rename($tmp, $dst) or croak "rename($tmp, $dst): $!";
207             }
208              
209             # Install a dir into ->dst, unless it already exists
210 8     8   9 sub _add_dir($self, $dir, $old) {
  8         25  
  8         20  
  8         8  
  8         8  
211 8         11 my $dst_abs= $self->dst_abs . $dir->{name};
212             # If the directory already exists, just apply the permissions
213 8 50 33     844 mkdir($dst_abs) || croak "mkdir($dst_abs): $!"
214             unless $old;
215 8         34 $self->_apply_stat($dst_abs, $dir);
216             }
217              
218             # Install a symlink into ->dst
219 5     5   7 sub _add_symlink($self, $file) {
  5         4  
  5         7  
  5         5  
220 5         7 my $dst_abs= $self->dst_abs . $file->{name};
221             length $file->{data}
222 5 50       11 or croak "Missing symlink contents for $file->{name}";
223 5 50       361 symlink($file->{data}, $dst_abs)
224             or croak "symlink($file->{data}, $dst_abs): $!";
225 5         29 $self->_apply_stat($dst_abs, $file);
226             }
227              
228             # Install a device node into ->dst
229 1     1   4 sub _add_devnode($self, $file) {
  1         2  
  1         3  
  1         3  
230 1 50 33     29 if (defined $file->{rdev} && (!defined $file->{rdev_major} || !defined $file->{rdev_minor})) {
      33        
231 0         0 my ($major,$minor)= Sys::Export::Unix::_dev_major_minor($file->{rdev});
232 0   0     0 $file->{rdev_major} //= $major;
233 0   0     0 $file->{rdev_minor} //= $minor;
234             }
235 1         3 my $dst_abs= $self->dst_abs . $file->{name};
236 1         9 Sys::Export::Unix::_mknod_or_die($dst_abs, $file->{mode}, $file->{rdev_major}, $file->{rdev_minor});
237 1         71 $self->_apply_stat($dst_abs, $file);
238             }
239              
240             # Install a fifo into ->dst
241 0     0   0 sub _add_fifo($self, $file) {
  0         0  
  0         0  
  0         0  
242 0         0 require POSIX;
243 0         0 my $dst_abs= $self->dst_abs . $file->{name};
244             POSIX::mkfifo($dst_abs, $file->{mode})
245 0 0       0 or croak "mkfifo($dst_abs): $!";
246 0         0 $self->_apply_stat($dst_abs, $file);
247             }
248              
249             # Bind a socket (thus creating it) in ->dst
250 1     1   2 sub _add_socket($self, $file) {
  1         3  
  1         2  
  1         3  
251 1         16 require Socket;
252 1         4 my $dst_abs= $self->dst_abs . $file->{name};
253 1 50       50 socket(my $s, Socket::AF_UNIX(), Socket::SOCK_STREAM(), 0) or die "socket: $!";
254 1 50       155 bind($s, Socket::pack_sockaddr_un($dst_abs)) or die "Failed to bind socket at $dst_abs: $!";
255 1         34 $self->_apply_stat($dst_abs, $file);
256             }
257              
258              
259 2     2 1 9 sub finish($self) {
  2         5  
  2         4  
260 2         96 my $todo= delete $self->{_delayed_apply_stat};
261             # Reverse sort causes child directories to be updated before parents,
262             # which is required for updating mtimes.
263             $self->_delayed_apply_stat(@$_)
264 2         21 for sort { $b->[0] cmp $a->[0] } @$todo;
  3         27  
265             # free the temp directory if it was located within /dst_abs
266 2         65 undef $self->{tmp};
267             }
268              
269             # Apply permissions and mtime to a path
270 20     20   77 sub _apply_stat($self, $abs_path, $stat) {
  20         32  
  20         26  
  20         25  
  20         31  
271 20 50       351 my ($mode, $uid, $gid, $atime, $mtime)= (lstat $abs_path)[2,4,5,8,9]
272             or croak "Failed to stat file just created at '$abs_path': $!";
273 20   66     102 my $change_uid= defined $stat->{uid} && $stat->{uid} != $uid;
274 20   66     48 my $change_gid= defined $stat->{gid} && $stat->{gid} != $gid;
275 20 50 33     72 if ($change_uid || $change_gid) {
276             # only UID 0 can change UID, and only GID 0 or GID in supplemental groups can change GID.
277 0 0 0     0 $uid= -1 unless $change_uid && $> == 0;
278 0 0 0     0 $gid= -1 unless $change_gid && ($) == 0 || grep $stat->{gid}, split / /, $) );
      0        
279             # Only attempt change if able
280 0 0 0     0 POSIX::lchown($uid, $gid, $abs_path) or croak "lchown($uid, $gid, $abs_path): $!"
      0        
281             if $uid >= 0 || $gid >= 0;
282             }
283              
284 20         25 my @delayed;
285              
286             # Don't change permission bits on symlinks
287 20 100 100     102 if (!S_ISLNK($mode) && ($mode & 0xFFF) != ($stat->{mode} & 0xFFF)) {
288             # If changing permissions on a directory to something that removes our ability
289             # to write to it, delay this change until the end.
290 6 50 33     21 if (S_ISDIR($mode) && !(($stat->{mode} & 0222) && ($stat->{mode} & 0111))) {
      66        
291 0         0 push @delayed, 'chmod';
292             }
293             else {
294             chmod $stat->{mode}&0xFFF, $abs_path
295 6 50       111 or croak sprintf "chmod(0%o, %s): $!", $stat->{mode}&0xFFF, $abs_path;
296             }
297             }
298              
299 20 100 66     84 if (!S_ISLNK($mode) && (defined $stat->{mtime} || defined $stat->{atime})) {
      66        
300 10 100       38 if (S_ISDIR($mode)) {
301             # No point in applying mtime to a directory now, because it will get
302             # changed when sub-entries get written.
303 5         9 push @delayed, 'utime';
304             }
305             else {
306 5 50       135 utime $stat->{atime}, $stat->{mtime}, $abs_path
307             or warn "utime($abs_path): $!";
308             }
309             }
310              
311 20 100       275 push @{$self->{_delayed_apply_stat}}, [ $abs_path, $stat, @delayed ]
  5         49  
312             if @delayed;
313             }
314 5     5   11 sub _delayed_apply_stat($self, $abs_path, $stat, @delayed) {
  5         7  
  5         7  
  5         6  
  5         9  
  5         6  
315 5 50       27 if (grep $_ eq 'chmod', @delayed) {
316             chmod $stat->{mode}&0xFFF, $abs_path
317 0 0       0 or croak sprintf "chmod(0%o, %s): $!", $stat->{mode}&0xFFF, $abs_path;
318             }
319 5 50       12 if (grep $_ eq 'utime', @delayed) {
320 5 50       131 utime $stat->{atime}, $stat->{mtime}, $abs_path
321             or warn "utime($abs_path): $!";
322             }
323             }
324              
325             # Avoiding dependency on namespace::clean
326             delete @{Sys::Export::Unix::WriteFS::}{qw(
327             carp croak abs_path blessed isa_hash
328             S_IFBLK S_IFCHR S_IFDIR S_IFIFO S_IFLNK S_IFMT S_IFREG S_IFSOCK S_IFWHT
329             S_ISBLK S_ISCHR S_ISDIR S_ISFIFO S_ISLNK S_ISREG S_ISSOCK S_ISWHT
330             )};
331             1;
332              
333             __END__