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   30 use warnings;
  5         11  
  5         174  
5 5     5   23 use strict;
  5         36  
  5         134  
6              
7 5     5   22 use Crypt::Digest qw(digest_data);
  5         10  
  5         277  
8 5     5   26 use File::KDBX::Constants qw(:magic :header :version :random_stream);
  5         9  
  5         1178  
9 5     5   32 use File::KDBX::Error;
  5         8  
  5         259  
10 5     5   27 use File::KDBX::Util qw(:class);
  5         10  
  5         433  
11 5     5   37 use File::KDBX;
  5         11  
  5         104  
12 5     5   451 use IO::Handle;
  5         5185  
  5         173  
13 5     5   30 use Module::Load;
  5         9  
  5         36  
14 5     5   251 use Ref::Util qw(is_ref is_scalarref);
  5         9  
  5         261  
15 5     5   30 use Scalar::Util qw(looks_like_number openhandle);
  5         10  
  5         199  
16 5     5   43 use namespace::clean;
  5         15  
  5         37  
17              
18             our $VERSION = '0.905'; # VERSION
19              
20              
21             sub new {
22 15     15 1 29 my $class = shift;
23 15         32 my $self = bless {}, $class;
24 15         42 $self->init(@_);
25             }
26              
27              
28             sub init {
29 30     30 1 47 my $self = shift;
30 30         73 my %args = @_;
31              
32 30         108 @$self{keys %args} = values %args;
33              
34 30         113 return $self;
35             }
36              
37             sub _rebless {
38 30     30   58 my $self = shift;
39 30   66     89 my $format = shift // $self->format;
40              
41 30         72 my $version = $self->kdbx->version;
42              
43 30         48 my $subclass;
44              
45 30 100       121 if (defined $format) {
    50          
    50          
    50          
46 15         24 $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         32 my $major = $version & KDBX_VERSION_MAJOR_MASK;
56 15         64 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       56 $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         128 load "File::KDBX::Dumper::$subclass";
73 30         1137 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 3 my $self = shift;
86 2         4 my $dst = shift;
87 2 50       7 return $self->dump_handle($dst, @_) if openhandle($dst);
88 2 50       5 return $self->dump_string($dst, @_) if is_scalarref($dst);
89 2 50 33     14 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 19 my $self = shift;
96 13 100       42 my $ref = is_scalarref($_[0]) ? shift : undef;
97 13 50       47 my %args = @_ % 2 == 0 ? @_ : (key => shift, @_);
98              
99 13         26 my $key = delete $args{key};
100 13   33     59 $args{kdbx} //= $self->kdbx;
101              
102 13   66     32 $ref //= do {
103 4         8 my $buf = '';
104 4         15 \$buf;
105             };
106              
107 13 50   3   237 open(my $fh, '>', $ref) or throw "Failed to open string buffer: $!";
  3         22  
  3         5  
  3         23  
108              
109 13 50       2268 $self = $self->new if !ref $self;
110 13         44 $self->init(%args, fh => $fh)->_dump($fh, $key);
111              
112 13         63 return $ref;
113             }
114              
115              
116             sub dump_file {
117 2     2 1 4 my $self = shift;
118 2         4 my $filepath = shift;
119 2 100       8 my %args = @_ % 2 == 0 ? @_ : (key => shift, @_);
120              
121 2         4 my $key = delete $args{key};
122 2         4 my $mode = delete $args{mode};
123 2         5 my $uid = delete $args{uid};
124 2         3 my $gid = delete $args{gid};
125 2   100     7 my $atomic = delete $args{atomic} // 1;
126              
127 2   33     8 $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         4  
133 1 50 33     283 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       89 open($fh, '>:raw', $filepath) or throw "Open file failed ($filepath): $!", filepath => $filepath;
142             }
143 2         19 $fh->autoflush(1);
144              
145 2 50       222 $self = $self->new if !ref $self;
146 2         9 $self->init(%args, fh => $fh, filepath => $filepath);
147 2         11 $self->_dump($fh, $key);
148 2         5 close($fh);
149              
150 2         32 my ($file_mode, $file_uid, $file_gid) = (stat($filepath))[2, 4, 5];
151              
152 2 100       10 if ($filepath_temp) {
153 1 0 33     30 $mode //= $file_mode // do { my $m = umask; defined $m ? oct(666) &~ $m : undef };
  0   33     0  
  0         0  
154 1   50     5 $uid //= $file_uid // -1;
      33        
155 1   50     5 $gid //= $file_gid // -1;
      33        
156 1 50       18 chmod($mode, $filepath_temp) if defined $mode;
157 1         18 chown($uid, $gid, $filepath_temp);
158 1 50       137 rename($filepath_temp, $filepath) or throw "Failed to write file ($filepath): $!",
159             filepath => $filepath;
160             }
161              
162 2         14 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 603 my $self = shift;
183 456 50       827 return File::KDBX->new if !ref $self;
184 456 50       737 $self->{kdbx} = shift if @_;
185 456   33     1374 $self->{kdbx} //= File::KDBX->new;
186 15 50   15 1 54 }
187 15 50   15 1 76  
188 15 50 50 7 1 66  
  7         26  
189 15 50 33 15 1 122 has 'format', is => 'ro';
  15         50  
190 7   33     39 has 'inner_format', is => 'ro', default => 'XML';
191 15   66     92 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         24 my $fh = shift;
199 15         20 my $key = shift;
200              
201 15         34 my $kdbx = $self->kdbx;
202              
203 15         43 my $min_version = $kdbx->minimum_version;
204 15 100 66     75 if ($kdbx->version < $min_version && $self->allow_upgrade) {
205 7         28 alert sprintf("Implicitly upgrading database from %x to %x\n", $kdbx->version, $min_version),
206             version => $kdbx->version, min_version => $min_version;
207 7         23 $kdbx->version($min_version);
208             }
209 15         56 $self->_rebless;
210              
211 15 50       99 if (ref($self) =~ /::(?:KDB|V[34])$/) {
212 15 0 33     40 $key //= $kdbx->key ? $kdbx->key->reload : undef;
213 15 50       33 defined $key or throw 'Must provide a master key', type => 'key.missing';
214             }
215              
216 15         100 $self->_prepare;
217              
218 15         52 my $magic = $self->_write_magic_numbers($fh);
219 15         56 my $headers = $self->_write_headers($fh);
220              
221 15         70 $kdbx->unlock;
222              
223 15         65 $self->_write_body($fh, $key, "$magic$headers");
224              
225 15         134 return $kdbx;
226             }
227              
228             sub _prepare {
229 15     15   22 my $self = shift;
230 15         41 my $kdbx = $self->kdbx;
231              
232 15 100       48 if ($kdbx->version < KDBX_VERSION_4_0) {
233             # force Salsa20 inner random stream
234 8         33 $kdbx->inner_random_stream_id(STREAM_ID_SALSA20);
235 8         24 my $key = $kdbx->inner_random_stream_key;
236 8         25 substr($key, 32) = '';
237 8         18 $kdbx->inner_random_stream_key($key);
238             }
239              
240 15 100       49 $kdbx->randomize_seeds if $self->randomize_seeds;
241             }
242              
243             sub _write_magic_numbers {
244 15     15   27 my $self = shift;
245 15         20 my $fh = shift;
246              
247 15         34 my $kdbx = $self->kdbx;
248              
249 15 50       46 $kdbx->sig1 == KDBX_SIG1 or throw 'Invalid file signature', sig1 => $kdbx->sig1;
250 15 50 33     43 $kdbx->version < KDBX_VERSION_OLDEST || KDBX_VERSION_LATEST < $kdbx->version
251             and throw 'Unsupported file version', version => $kdbx->version;
252              
253 15         41 my @magic = ($kdbx->sig1, $kdbx->sig2, $kdbx->version);
254              
255 15         63 my $buf = pack('L<3', @magic);
256 15 50       76 $fh->print($buf) or throw 'Failed to write file signature';
257              
258 15         234 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   32 my $self = shift;
267              
268 15         26 my $current_pkg = ref $self;
269 15         88 require Scope::Guard;
270 15     15   112 my $guard = Scope::Guard->new(sub { bless $self, $current_pkg });
  15         4814  
271              
272 15         195 $self->_rebless($self->inner_format);
273 15         60 $self->_write_inner_body(@_);
274             }
275              
276             1;
277              
278             __END__