File Coverage

blib/lib/IO/BlockSync.pm
Criterion Covered Total %
statement 38 122 31.1
branch 0 42 0.0
condition 0 27 0.0
subroutine 13 19 68.4
pod 2 2 100.0
total 53 212 25.0


line stmt bran cond sub pod time code
1             package IO::BlockSync;
2              
3             # Basic
4 1     1   53029 use 5.010;
  1         4  
5 1     1   5 use strict;
  1         1  
  1         30  
6 1     1   5 use warnings FATAL => 'all';
  1         2  
  1         30  
7              
8             # Build in
9 1     1   4 use Carp;
  1         2  
  1         62  
10 1     1   6 use Fcntl qw(:DEFAULT :seek);
  1         2  
  1         360  
11 1     1   429 use POSIX qw(ceil);
  1         5098  
  1         4  
12 1     1   1262 use Scalar::Util qw(reftype);
  1         2  
  1         47  
13              
14             # CPAN
15 1     1   687 use Log::Log4perl;
  1         38133  
  1         5  
16 1     1   46 use Log::Log4perl::Level;
  1         2  
  1         4  
17 1     1   612 use Moo;
  1         9160  
  1         9  
18 1     1   1736 use Try::Tiny;
  1         1062  
  1         49  
19              
20             # These two come last - in that order
21 1     1   401 use namespace::clean;
  1         7768  
  1         5  
22 1     1   1068 use Exporter qw(import);
  1         2  
  1         1221  
23              
24             ################################################################
25              
26             # Moo roles to implement
27             with('MooseX::Log::Log4perl');
28              
29             # Make sure log4perl doesn't come with errors
30             if ( not Log::Log4perl->initialized() ) {
31             Log::Log4perl->easy_init( Log::Log4perl::Level::to_priority('OFF') );
32             }
33              
34             ################################################################
35              
36             =head1 NAME
37              
38             IO::BlockSync - Syncronize changed blocks
39              
40             =head1 VERSION
41              
42             Version 0.001
43              
44             =cut
45              
46             our $VERSION = '0.001';
47              
48             ################################################################
49              
50             =head1 SYNOPSIS
51              
52             BlockSync can some of the same stuff that bigsync (by Egor Egorov) can
53             - it's just written in perl.
54              
55             BlockSync copies data from source file to destination file (can be a block
56             device) and calculates checksum on each block it copies.
57             On all runs after the first only the changed blocks will be copied.
58              
59             use IO::BlockSync;
60              
61             # OOP way
62             my $bs = IO::BlockSync->new(
63             src => '/path/to/source/file',
64             dst => '/path/to/destination/file',
65             chk => '/path/to/chk/file',
66             );
67             $bs->run;
68              
69             # Non OOP way
70             BlockSync(
71             src => '/path/to/source/file',
72             dst => '/path/to/destination/file',
73             chk => '/path/to/chk/file',
74             );
75              
76             =cut
77              
78             ################################################################
79              
80             =head1 EXPORT
81              
82             =cut
83              
84             our @EXPORT = qw(BlockSync);
85              
86             ################################################################
87              
88             =head2 BlockSync
89              
90             Run BlockSync non-object-oriented
91              
92             =cut
93              
94             sub BlockSync {
95 0     0 1   return __PACKAGE__->new(@_)->run;
96             }
97              
98             ################################################################
99              
100             =head1 ATTRIBUTES
101              
102             =cut
103              
104             ################################################################
105              
106             =head2 src
107              
108             Path to source file.
109              
110             mandatory - string (containing path) or filehandle
111              
112             =cut
113              
114             has 'src' => (
115             is => 'ro',
116             required => 1,
117             );
118              
119             ################################################################
120              
121             =head2 dst
122              
123             Destination file. If not set, then only checksum file will be updated.
124              
125             optional - string (containing path) or filehandle
126              
127             =cut
128              
129             has 'dst' => ( is => 'ro', );
130              
131             ################################################################
132              
133             =head2 chk
134              
135             Path to checksum file.
136              
137             mandatory - string (containing path) or filehandle
138              
139             =cut
140              
141             has 'chk' => (
142             is => 'ro',
143             required => 1,
144             );
145              
146             ################################################################
147              
148             =head2 bs
149              
150             Block size to use in bytes.
151              
152             optional - integer - defaults to 1_048_576 B (1 MB)
153              
154             =cut
155              
156             has 'bs' => (
157             is => 'ro',
158             default => 1_048_576,
159             );
160              
161             ################################################################
162              
163             =head2 hash
164              
165             Sub that retrurn hashed data.
166              
167             optional - sub - defaults to sub that return MD5 hash followed by newline
168              
169             =cut
170              
171             has 'hash' => (
172             is => 'ro',
173             default => sub {
174             require Digest::MD5;
175             sub {
176             Digest::MD5::md5_hex(shift) . "\n";
177             }
178             },
179             );
180              
181             ################################################################
182              
183             =head2 sparse
184              
185             Seek in dst file, instead of writing blocks only containing \0
186              
187             optional - boolean - defaults to 0 (false)
188              
189             =cut
190              
191             has 'sparse' => (
192             is => 'ro',
193             default => 0,
194             );
195              
196             ################################################################
197              
198             =head2 truncate
199              
200             Truncate the destination file to same size as source file. Does not work on block devices. Will only be tried if C<data> has default value (whole file is copied).
201              
202             optional - boolean - defaults to 0 (false)
203              
204             =cut
205              
206             has 'truncate' => (
207             is => 'ro',
208             default => 0,
209             );
210              
211             ################################################################
212              
213             =head2 data
214              
215             List of areas (in bytes) inside the source file that should be looked at.
216             Usefull if you know excactly which blocks in src that could have changed.
217              
218             data => [
219             {start => 0, end => 9999},
220             {start => 88888, end => 777777},
221             ]
222              
223             optional - array of hashes - defaults to "whole file"
224              
225             =cut
226              
227             has 'data' => (
228             is => 'ro',
229             default => sub {
230             [
231             {
232             start => 0,
233             end => 0
234             }
235             ]
236             },
237             );
238              
239             ################################################################
240              
241             =head2 status
242              
243             Sub that will be run everytime a block has been read (and written).
244              
245             optional - sub - default to sub doing nothing
246              
247             =cut
248              
249             has 'status' => (
250             is => 'ro',
251             default => sub {
252             sub { }
253             },
254             );
255              
256             ################################################################
257              
258             =head1 METHODS
259              
260             =cut
261              
262             ################################################################
263              
264             =head2 run
265              
266             This is the method that starts copying data.
267              
268             =cut
269              
270             sub run { ## no critic (Subroutines::ProhibitExcessComplexity)
271 0     0 1   my $self = shift;
272              
273 0           my ( $srcFh, $srcClose, $dstFh, $dstClose, $chkFh, $chkClose );
274              
275             try {
276             # Get file handles for source, destination and checksum files
277 0     0     $srcFh = $self->_getFh( 'src', $self->src, \$srcClose, O_RDONLY );
278 0           $chkFh =
279             $self->_getFh( 'chk', $self->chk, \$chkClose, O_RDWR | O_CREAT );
280 0 0         if ( $self->dst ) {
281 0           $dstFh = $self->_getFh( 'dst', $self->dst, \$dstClose,
282             O_WRONLY | O_CREAT );
283             }
284             else {
285 0           $self->logger->debug('No dst file, only calculating checksums');
286             }
287              
288             # Calculate hash for a block only containing ASCII 0
289 0           my $nullHash = &{ $self->hash }( "\0" x $self->bs );
  0            
290              
291             # Get number of bytes that a hash takes up
292 0           my $hashSize = length($nullHash);
293              
294 0           my $srcSeek;
295              
296             # Loop through "areas" that should be copied
297             # Default i one area containing the whole source file
298 0           foreach my $dataBlocks ( @{ $self->data } ) {
  0            
299              
300             # Start and end of this "area" (default is $start=0, $end=0)
301 0           my $start = $dataBlocks->{start};
302 0           my $end = $dataBlocks->{end};
303              
304 0           $self->logger->debug(
305             "Going to process data from <$start> to <$end>");
306              
307             # Seek to $start
308             # (or the beginning of the block that $start is in,
309             # if $start is not aligned with bs)
310 0           $srcSeek = int( $start / $self->bs ) * $self->bs;
311 0 0         sysseek( $srcFh, $srcSeek, SEEK_SET )
312             || $self->logger->logcroak(
313             "Cannot seek to block <$srcSeek> in src file");
314              
315             # Just die! Muhahaha. Or not
316 0           my $die = 0;
317              
318             # Can be either sparse, new, unchanged or changed
319 0           my $status;
320              
321             # Read block from source
322 0           while ( my $srcReadSize = sysread( $srcFh, my $data, $self->bs ) ) {
323              
324             # It's ok to read a block smaller than bs if it's the last
325             # block. But it's not ok if it's not the last.
326 0 0         if ($die) {
327 0           croak 'not reading full block';
328             }
329 0           $die = $srcReadSize != $self->bs;
330              
331             # $block = block number in source with the specified block size
332 0           my $block = $srcSeek / $self->bs;
333              
334             # We start by assuming that we should write to dst
335             # - if dst is set (= we are not just calculating checksum)
336 0           my $writeData = 1 && $dstFh;
337              
338             # We start be assuming that we should write checksum to chk
339 0           my $writeHash = 1;
340              
341             # Calculate hash for data read from src
342 0           my $newHash = &{ $self->hash }($data);
  0            
343              
344             # Get old hash for the same block
345 0 0         sysseek( $chkFh, $block * $hashSize, SEEK_SET )
346             || $self->logger->logcroak('Cannot seek in chk file');
347 0           my $oldHashSize = sysread( $chkFh, my $oldHash, $hashSize );
348              
349             # Test source against checksum
350 0 0 0       if ( $oldHashSize != $hashSize || $oldHash eq "\0" x $hashSize )
    0          
351             {
352 0 0 0       if ( $self->sparse && $newHash eq $nullHash ) {
353              
354             # Sparse is only for new blocks
355             # Blocks that have been nulled out in source will
356             # also get nulled out in destination
357 0           $status = 'sparse';
358 0           $writeData = 0;
359             }
360             else {
361 0           $status = 'new';
362             }
363             }
364             elsif ( $newHash eq $oldHash ) {
365 0           $status = 'unchanged';
366 0           $writeData = 0;
367 0           $writeHash = 0;
368             }
369             else {
370 0           $status = 'changed';
371             }
372              
373             # Write data to destination
374 0 0         if ($writeData) {
375 0 0         sysseek( $dstFh, $srcSeek, SEEK_SET )
376             || $self->logger->logcroak('Cannot seek in dst file');
377 0           syswrite( $dstFh, $data );
378             }
379              
380             # Update hash in checksum
381 0 0         if ($writeHash) {
382 0 0         sysseek( $chkFh, $block * $hashSize, SEEK_SET )
383             || $self->logger->logcroak('Cannot seek in chk file');
384 0           syswrite( $chkFh, $newHash );
385             }
386              
387             $self->logger->debug(
388 0           sprintf 'Block <%u> was <%s> (<%u> to <%u>)',
389             $block, $status, $srcSeek, $srcSeek + $srcReadSize - 1 );
390 0           &{ $self->status }
  0            
391             ( $block, $status, $srcSeek, $srcSeek + $srcReadSize - 1 );
392              
393             # Next block will start here
394 0           $srcSeek += $srcReadSize;
395              
396             # Was this the last block in this batch
397 0 0 0       if ( $end && $srcSeek > $end ) {
398 0           last;
399             }
400             }
401              
402             # If last block is sparse, it is not enough to seek to where the
403             # EOF should be. We need to at least write a single \0
404 0 0 0       if ( $dstFh && $status eq 'sparse' ) {
405 0 0         sysseek( $dstFh, $srcSeek - 1, SEEK_SET )
406             || $self->logger->logcroak('Cannot seek in dst file');
407 0           syswrite( $dstFh, "\0" );
408             }
409             }
410              
411 0 0 0       if ( $self->truncate
      0        
      0        
      0        
      0        
412             && $dstFh
413             && $srcSeek
414 0           && @{ $self->data } == 1
415             && $self->data->[0]->{start} == 0
416             && $self->data->[0]->{end} == 0 )
417             {
418 0           $self->logger->debug("Truncating dst file at <$srcSeek>");
419 0           truncate( $dstFh, $srcSeek );
420              
421 0           my $chkSeek = ceil( $srcSeek / $self->bs ) * $hashSize;
422 0           $self->logger->debug("Truncating chk file at <$chkSeek>");
423 0           truncate( $chkFh, $chkSeek );
424             }
425              
426             }
427             catch {
428 0     0     croak $_;
429             }
430             finally {
431             # If we opened the files (we got string with path), then we close them
432             # if we got a filehandle then we do nothing
433 0 0   0     if ($srcClose) {
434 0           $self->logger->debug( sprintf 'closing src file <%s>', $self->src );
435             }
436 0 0         if ($dstClose) {
437 0           $self->logger->debug( sprintf 'closing dst file <%s>', $self->dst );
438             }
439 0 0         if ($chkClose) {
440 0           $self->logger->debug( sprintf 'closing chk file <%s>', $self->chk );
441             }
442 0           };
443              
444             # Make Perl::Critic happy
445 0           return;
446             }
447              
448             ################################################################
449              
450             =begin comment
451              
452             Private
453             Get file handle
454              
455             =end comment
456              
457             =cut
458              
459             sub _getFh {
460 0     0     my ( $self, $name, $file, $closeFile, $mode ) = @_;
461              
462 0 0         if ( my $t = reftype($file) ) {
463 0 0         if ( $t eq 'GLOB' ) {
464 0           $self->logger->debug(
465             sprintf '%s is a file handle, using that directly', $name );
466 0           return $file;
467             }
468             else {
469 0           $self->logger->logcroak(
470             sprintf '<%s> is not a supported type for %s',
471             $t, $name );
472             }
473             }
474             else {
475 0           $self->logger->debug( sprintf 'opening %s file <%s>', $name, $file );
476 0 0         sysopen( my $fh, $file, $mode )
477             || $self->logger->logcroak( sprintf 'error opening <%s>', $file );
478 0           ${$closeFile} = 1;
  0            
479 0           return $fh;
480             }
481              
482             # Make Perl::Critic happy
483 0           croak 'We should never end here!';
484             }
485              
486             ################################################################
487              
488             =head1 LICENSE AND COPYRIGHT
489              
490             This software is copyright (c) 2019 by Thor Dreier-Hansen.
491              
492             This is free software; you can redistribute it and/or modify it under
493             the same terms as the Perl 5 programming language system itself.
494              
495             Terms of the Perl programming language system itself:
496              
497             =over
498              
499             =item * the
500             L<GNU General Public License|http://dev.perl.org/licenses/gpl1.html>
501             as published by the Free Software Foundation; either
502             L<version 1|http://dev.perl.org/licenses/gpl1.html>,
503             or (at your option) any later version, or
504              
505             =item * the L<"Artistic License"|http://dev.perl.org/licenses/artistic.html>
506              
507             =back
508              
509             See L<http://dev.perl.org/licenses/> for more information.
510              
511             =cut
512              
513             1; # End of IO::BlockSync