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