File Coverage

blib/lib/Sys/Export/Unix/WriteFS.pm
Criterion Covered Total %
statement 145 217 66.8
branch 52 132 39.3
condition 29 111 26.1
subroutine 21 28 75.0
pod 7 7 100.0
total 254 495 51.3


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