File Coverage

blib/lib/File/KDBX/Dumper.pm
Criterion Covered Total %
statement 159 183 86.8
branch 43 82 52.4
condition 26 64 40.6
subroutine 29 34 85.2
pod 12 12 100.0
total 269 375 71.7


line stmt bran cond sub pod time code
1             package File::KDBX::Dumper;
2             # ABSTRACT: Write KDBX files
3              
4 5     5   36 use warnings;
  5         8  
  5         180  
5 5     5   25 use strict;
  5         11  
  5         121  
6              
7 5     5   22 use Crypt::Digest qw(digest_data);
  5         12  
  5         284  
8 5     5   26 use File::KDBX::Constants qw(:magic :header :version :random_stream);
  5         9  
  5         1222  
9 5     5   31 use File::KDBX::Error;
  5         8  
  5         248  
10 5     5   27 use File::KDBX::Util qw(:class);
  5         9  
  5         426  
11 5     5   30 use File::KDBX;
  5         9  
  5         125  
12 5     5   468 use IO::Handle;
  5         5303  
  5         165  
13 5     5   27 use Module::Load;
  5         9  
  5         46  
14 5     5   269 use Ref::Util qw(is_ref is_scalarref);
  5         9  
  5         221  
15 5     5   25 use Scalar::Util qw(looks_like_number openhandle);
  5         8  
  5         212  
16 5     5   24 use namespace::clean;
  5         10  
  5         39  
17              
18             our $VERSION = '0.906'; # VERSION
19              
20              
21             sub new {
22 15     15 1 31 my $class = shift;
23 15         36 my $self = bless {}, $class;
24 15         59 $self->init(@_);
25             }
26              
27              
28             sub init {
29 30     30 1 50 my $self = shift;
30 30         82 my %args = @_;
31              
32 30         115 @$self{keys %args} = values %args;
33              
34 30         131 return $self;
35             }
36              
37             sub _rebless {
38 30     30   52 my $self = shift;
39 30   66     112 my $format = shift // $self->format;
40              
41 30         76 my $version = $self->kdbx->version;
42              
43 30         60 my $subclass;
44              
45 30 100       104 if (defined $format) {
    50          
    50          
    50          
46 15         28 $subclass = $format;
47             }
48             elsif (!defined $version) {
49 0         0 $subclass = 'XML';
50             }
51             elsif ($self->kdbx->sig2 == KDBX_SIG2_1) {
52 0         0 $subclass = 'KDB';
53             }
54             elsif (looks_like_number($version)) {
55 15         36 my $major = $version & KDBX_VERSION_MAJOR_MASK;
56 15         82 my %subclasses = (
57             KDBX_VERSION_2_0() => 'V3',
58             KDBX_VERSION_3_0() => 'V3',
59             KDBX_VERSION_4_0() => 'V4',
60             );
61 15 50       39 if ($major == KDBX_VERSION_2_0) {
62 0         0 alert sprintf("Upgrading KDBX version %x to version %x\n", $version, KDBX_VERSION_3_1);
63 0         0 $self->kdbx->version(KDBX_VERSION_3_1);
64             }
65 15 50       63 $subclass = $subclasses{$major}
66             or throw sprintf('Unsupported KDBX file version: %x', $version), version => $version;
67             }
68             else {
69 0         0 throw sprintf('Unknown file version: %s', $version), version => $version;
70             }
71              
72 30         129 load "File::KDBX::Dumper::$subclass";
73 30         1263 bless $self, "File::KDBX::Dumper::$subclass";
74             }
75              
76              
77             sub reset {
78 0     0 1 0 my $self = shift;
79 0         0 %$self = ();
80 0         0 return $self;
81             }
82              
83              
84             sub dump {
85 2     2 1 5 my $self = shift;
86 2         117 my $dst = shift;
87 2 50       42 return $self->dump_handle($dst, @_) if openhandle($dst);
88 2 50       11 return $self->dump_string($dst, @_) if is_scalarref($dst);
89 2 50 33     23 return $self->dump_file($dst, @_) if defined $dst && !is_ref($dst);
90 0         0 throw 'Programmer error: Must pass a stringref, filepath or IO handle to dump';
91             }
92              
93              
94             sub dump_string {
95 13     13 1 23 my $self = shift;
96 13 100       50 my $ref = is_scalarref($_[0]) ? shift : undef;
97 13 50       57 my %args = @_ % 2 == 0 ? @_ : (key => shift, @_);
98              
99 13         27 my $key = delete $args{key};
100 13   33     67 $args{kdbx} //= $self->kdbx;
101              
102 13   66     70 $ref //= do {
103 4         5 my $buf = '';
104 4         14 \$buf;
105             };
106              
107 13 50   3   243 open(my $fh, '>', $ref) or throw "Failed to open string buffer: $!";
  3         16  
  3         4  
  3         21  
108              
109 13 50       2099 $self = $self->new if !ref $self;
110 13         44 $self->init(%args, fh => $fh)->_dump($fh, $key);
111              
112 13         76 return $ref;
113             }
114              
115              
116             sub dump_file {
117 2     2 1 4 my $self = shift;
118 2         5 my $filepath = shift;
119 2 100       13 my %args = @_ % 2 == 0 ? @_ : (key => shift, @_);
120              
121 2         7 my $key = delete $args{key};
122 2         5 my $mode = delete $args{mode};
123 2         5 my $uid = delete $args{uid};
124 2         4 my $gid = delete $args{gid};
125 2   100     9 my $atomic = delete $args{atomic} // 1;
126              
127 2   33     14 $args{kdbx} //= $self->kdbx;
128              
129 2         3 my ($fh, $filepath_temp);
130 2 100       6 if ($atomic) {
131 1         5 require File::Temp;
132 1         2 ($fh, $filepath_temp) = eval { File::Temp::tempfile("${filepath}-XXXXXX", UNLINK => 1) };
  1         5  
133 1 50 33     309 if (!$fh or my $err = $@) {
134 0   0     0 $err //= 'Unknown error';
135 0         0 throw sprintf('Open file failed (%s): %s', $filepath_temp, $err),
136             error => $err,
137             filepath => $filepath_temp;
138             }
139             }
140             else {
141 1 50       186 open($fh, '>:raw', $filepath) or throw "Open file failed ($filepath): $!", filepath => $filepath;
142             }
143 2         30 $fh->autoflush(1);
144              
145 2 50       121 $self = $self->new if !ref $self;
146 2         11 $self->init(%args, fh => $fh, filepath => $filepath);
147 2         8 $self->_dump($fh, $key);
148 2         8 close($fh);
149              
150 2         57 my ($file_mode, $file_uid, $file_gid) = (stat($filepath))[2, 4, 5];
151              
152 2 100       12 if ($filepath_temp) {
153 1 0 33     8 $mode //= $file_mode // do { my $m = umask; defined $m ? oct(666) &~ $m : undef };
  0   33     0  
  0         0  
154 1   50     7 $uid //= $file_uid // -1;
      33        
155 1   50     6 $gid //= $file_gid // -1;
      33        
156 1 50       20 chmod($mode, $filepath_temp) if defined $mode;
157 1         25 chown($uid, $gid, $filepath_temp);
158 1 50       177 rename($filepath_temp, $filepath) or throw "Failed to write file ($filepath): $!",
159             filepath => $filepath;
160             }
161              
162 2         20 return $self;
163             }
164              
165              
166             sub dump_handle {
167 0     0 1 0 my $self = shift;
168 0         0 my $fh = shift;
169 0 0       0 my %args = @_ % 2 == 0 ? @_ : (key => shift, @_);
170              
171 0 0       0 $fh = *STDOUT if $fh eq '-';
172              
173 0         0 my $key = delete $args{key};
174 0   0     0 $args{kdbx} //= $self->kdbx;
175              
176 0 0       0 $self = $self->new if !ref $self;
177 0         0 $self->init(%args, fh => $fh)->_dump($fh, $key);
178             }
179              
180              
181             sub kdbx {
182 456     456 1 585 my $self = shift;
183 456 50       827 return File::KDBX->new if !ref $self;
184 456 50       784 $self->{kdbx} = shift if @_;
185 456   33     1545 $self->{kdbx} //= File::KDBX->new;
186 15 50   15 1 60 }
187 15 50   15 1 87  
188 15 50 50 7 1 81  
  7         31  
189 15 50 33 15 1 157 has 'format', is => 'ro';
  15         55  
190 7   33     46 has 'inner_format', is => 'ro', default => 'XML';
191 15   66     110 has 'allow_upgrade', is => 'ro', default => 1;
192             has 'randomize_seeds', is => 'ro', default => 1;
193              
194 0 0   0   0 sub _fh { $_[0]->{fh} or throw 'IO handle not set' }
195              
196             sub _dump {
197 15     15   28 my $self = shift;
198 15         22 my $fh = shift;
199 15         29 my $key = shift;
200              
201 15         30 my $kdbx = $self->kdbx;
202              
203 15         56 my $min_version = $kdbx->minimum_version;
204 15 100 66     87 if ($kdbx->version < $min_version && $self->allow_upgrade) {
205 7         22 alert sprintf("Implicitly upgrading database from %x to %x\n", $kdbx->version, $min_version),
206             version => $kdbx->version, min_version => $min_version;
207 7         27 $kdbx->version($min_version);
208             }
209 15         58 $self->_rebless;
210              
211 15 50       111 if (ref($self) =~ /::(?:KDB|V[34])$/) {
212 15 0 33     41 $key //= $kdbx->key ? $kdbx->key->reload : undef;
213 15 50       47 defined $key or throw 'Must provide a master key', type => 'key.missing';
214             }
215              
216 15         65 $self->_prepare;
217              
218 15         73 my $magic = $self->_write_magic_numbers($fh);
219 15         63 my $headers = $self->_write_headers($fh);
220              
221 15         77 $kdbx->unlock;
222              
223 15         72 $self->_write_body($fh, $key, "$magic$headers");
224              
225 15         157 return $kdbx;
226             }
227              
228             sub _prepare {
229 15     15   33 my $self = shift;
230 15         42 my $kdbx = $self->kdbx;
231              
232 15 100       55 if ($kdbx->version < KDBX_VERSION_4_0) {
233             # force Salsa20 inner random stream
234 8         36 $kdbx->inner_random_stream_id(STREAM_ID_SALSA20);
235 8         28 my $key = $kdbx->inner_random_stream_key;
236 8         25 substr($key, 32) = '';
237 8         23 $kdbx->inner_random_stream_key($key);
238             }
239              
240 15 100       72 $kdbx->randomize_seeds if $self->randomize_seeds;
241             }
242              
243             sub _write_magic_numbers {
244 15     15   32 my $self = shift;
245 15         23 my $fh = shift;
246              
247 15         42 my $kdbx = $self->kdbx;
248              
249 15 50       58 $kdbx->sig1 == KDBX_SIG1 or throw 'Invalid file signature', sig1 => $kdbx->sig1;
250 15 50 33     34 $kdbx->version < KDBX_VERSION_OLDEST || KDBX_VERSION_LATEST < $kdbx->version
251             and throw 'Unsupported file version', version => $kdbx->version;
252              
253 15         37 my @magic = ($kdbx->sig1, $kdbx->sig2, $kdbx->version);
254              
255 15         70 my $buf = pack('L<3', @magic);
256 15 50       73 $fh->print($buf) or throw 'Failed to write file signature';
257              
258 15         289 return $buf;
259             }
260              
261 0     0   0 sub _write_headers { die "Not implemented" }
262              
263 0     0   0 sub _write_body { die "Not implemented" }
264              
265             sub _write_inner_body {
266 15     15   38 my $self = shift;
267              
268 15         33 my $current_pkg = ref $self;
269 15         97 require Scope::Guard;
270 15     15   116 my $guard = Scope::Guard->new(sub { bless $self, $current_pkg });
  15         5195  
271              
272 15         206 $self->_rebless($self->inner_format);
273 15         78 $self->_write_inner_body(@_);
274             }
275              
276             1;
277              
278             __END__