File Coverage

blib/lib/File/KDBX.pm
Criterion Covered Total %
statement 545 637 85.5
branch 215 390 55.1
condition 151 300 50.3
subroutine 138 157 87.9
pod 101 104 97.1
total 1150 1588 72.4


line stmt bran cond sub pod time code
1             package File::KDBX;
2             # ABSTRACT: Encrypted database to store secret text and files
3              
4 12     12   637773 use 5.010;
  12         45  
5 12     12   62 use warnings;
  12         23  
  12         294  
6 12     12   67 use strict;
  12         228  
  12         325  
7              
8 12     12   3065 use Crypt::Digest qw(digest_data);
  12         5171  
  12         616  
9 12     12   70 use Crypt::PRNG qw(random_bytes);
  12         28  
  12         418  
10 12     12   2710 use Devel::GlobalDestruction;
  12         3251  
  12         71  
11 12     12   706 use File::KDBX::Constants qw(:all :icon);
  12         26  
  12         9793  
12 12     12   85 use File::KDBX::Error;
  12         20  
  12         612  
13 12     12   4376 use File::KDBX::Safe;
  12         26  
  12         450  
14 12     12   73 use File::KDBX::Util qw(:class :coercion :empty :search :uuid erase simple_expression_query snakify);
  12         21  
  12         2285  
15 12     12   4436 use Hash::Util::FieldHash qw(fieldhashes);
  12         5476  
  12         556  
16 12     12   66 use List::Util qw(any first);
  12         1063  
  12         1577  
17 12     12   61 use Ref::Util qw(is_ref is_arrayref is_plain_hashref);
  12         20  
  12         485  
18 12     12   57 use Scalar::Util qw(blessed);
  12         20  
  12         1429  
19 12     12   962 use Time::Piece 1.33;
  12         214  
  12         80  
20 12     12   875 use boolean;
  12         29  
  12         97  
21 12     12   577 use namespace::clean;
  12         1162  
  12         74  
22              
23             our $VERSION = '0.904'; # VERSION
24             our $WARNINGS = 1;
25              
26             fieldhashes \my (%SAFE, %KEYS);
27              
28              
29             sub new {
30 59     59 1 82643 my $class = shift;
31              
32             # copy constructor
33 59 0 33     258 return $_[0]->clone if @_ == 1 && blessed $_[0] && $_[0]->isa($class);
      33        
34              
35 59         144 my $self = bless {}, $class;
36 59         236 $self->init(@_);
37 59 50       235 $self->_set_nonlazy_attributes if empty $self;
38 59         464 return $self;
39             }
40              
41 56 50   56   22331 sub DESTROY { local ($., $@, $!, $^E, $?); !in_global_destruction and $_[0]->reset }
  56         1227  
42              
43              
44             sub init {
45 59     59 1 99 my $self = shift;
46 59         126 my %args = @_;
47              
48 59         212 @$self{keys %args} = values %args;
49              
50 59         132 return $self;
51             }
52              
53              
54             sub reset {
55 86     86 1 531 my $self = shift;
56 86         227 erase $self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY};
57 86         262 erase $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY};
58 86         299 erase $self->{raw};
59 86         3052 %$self = ();
60 86         453 $self->_remove_safe;
61 86         877 return $self;
62             }
63              
64              
65             sub clone {
66 1     1 1 2 my $self = shift;
67 1         4 require Storable;
68 1         26 return Storable::dclone($self);
69             }
70              
71             sub STORABLE_freeze {
72 1     1 0 4 my $self = shift;
73 1         1 my $cloning = shift;
74              
75 1         6 my $copy = {%$self};
76              
77 1   33     91 return '', $copy, $KEYS{$self} // (), $SAFE{$self} // ();
      33        
78             }
79              
80             sub STORABLE_thaw {
81 1     1 0 2 my $self = shift;
82 1         2 my $cloning = shift;
83 1         1 shift;
84 1         1 my $clone = shift;
85 1         2 my $key = shift;
86 1         1 my $safe = shift;
87              
88 1         5 @$self{keys %$clone} = values %$clone;
89 1         6 $KEYS{$self} = $key;
90 1         3 $SAFE{$self} = $safe;
91              
92             # Dualvars aren't cloned as dualvars, so coerce the compression flags.
93 1         3 $self->compression_flags($self->compression_flags);
94              
95 1     3   3 $self->objects(history => 1)->each(sub { $_->kdbx($self) });
  3         6  
96             }
97              
98             ##############################################################################
99              
100              
101 9     9 1 14971 sub load { shift->_loader->load(@_) }
102 21     21 1 1377 sub load_string { shift->_loader->load_string(@_) }
103 0     0 1 0 sub load_file { shift->_loader->load_file(@_) }
104 0     0 1 0 sub load_handle { shift->_loader->load_handle(@_) }
105              
106             sub _loader {
107 30     30   64 my $self = shift;
108 30 50       152 $self = $self->new if !ref $self;
109 30         2360 require File::KDBX::Loader;
110 30         226 File::KDBX::Loader->new(kdbx => $self);
111             }
112              
113              
114 2     2 1 1660 sub dump { shift->_dumper->dump(@_) }
115 13     13 1 231 sub dump_string { shift->_dumper->dump_string(@_) }
116 0     0 1 0 sub dump_file { shift->_dumper->dump_file(@_) }
117 0     0 1 0 sub dump_handle { shift->_dumper->dump_handle(@_) }
118              
119             sub _dumper {
120 15     15   34 my $self = shift;
121 15 50       55 $self = $self->new if !ref $self;
122 15         2870 require File::KDBX::Dumper;
123 15         118 File::KDBX::Dumper->new(kdbx => $self);
124             }
125              
126             ##############################################################################
127              
128              
129             sub user_agent_string {
130 15     15 1 79 require Config;
131             sprintf('%s/%s (%s/%s; %s/%s; %s)',
132 15 50   156 1 736 __PACKAGE__, $VERSION, @Config::Config{qw(package version osname osvers archname)});
  156         1073  
133 156 100   177 1 502 }
  177 50       633  
134 156 100 66 605 1 754  
  177 50       529  
  605         2412  
135 177 100 66 1978 1 878 has sig1 => KDBX_SIG1, coerce => \&to_number;
  605 50       1364  
  1978         31711  
136 605 50 66 412 1 2675 has sig2 => KDBX_SIG2_2, coerce => \&to_number;
  1978 50       3413  
  412         1167  
137 1978 50 100 1870 1 8960 has version => KDBX_VERSION_3_1, coerce => \&to_number;
  412 50       892  
  1870         36248  
138 412 50 100 106 1 1760 has headers => {};
  1870 50       2990  
  106         390  
139 1870 100 100 154 1 10387 has inner_headers => {};
  106 50       295  
  154         1164  
140 106 100 100 59 1 442 has meta => {};
  154 50       436  
  59         230  
141 154 50 100     780 has binaries => {};
  59         165  
142 59   50     396 has deleted_objects => {};
143 59 50   59 1 244 has raw => coerce => \&to_string;
144 59 50   59 1 171  
  59 50       258  
145 59 50 33 61 1 179 # HEADERS
  59 50       155  
  61         220  
146 59 100 33 73 1 156 has 'headers.comment' => '', coerce => \&to_string;
  61 50       137  
  73         370  
147 61 100 66 73 1 148 has 'headers.cipher_id' => CIPHER_UUID_CHACHA20, coerce => \&to_uuid;
  73 50       214  
  73         2323  
148 73 100 100 73 1 153 has 'headers.compression_flags' => COMPRESSION_GZIP, coerce => \&to_compression_constant;
  73 50       280  
  73         823  
149 73 100 100     191 has 'headers.master_seed' => sub { random_bytes(32) }, coerce => \&to_string;
  73         252  
150 73   100     202 has 'headers.encryption_iv' => sub { random_bytes(16) }, coerce => \&to_string;
151             has 'headers.stream_start_bytes' => sub { random_bytes(32) }, coerce => \&to_string;
152             has 'headers.kdf_parameters' => sub {
153             +{
154             KDF_PARAM_UUID() => KDF_UUID_AES,
155 265 50   265 1 1562 KDF_PARAM_AES_ROUNDS() => $_[0]->headers->{+HEADER_TRANSFORM_ROUNDS} // KDF_DEFAULT_AES_ROUNDS,
156 265 100       635 KDF_PARAM_AES_SEED() => $_[0]->headers->{+HEADER_TRANSFORM_SEED} // random_bytes(32),
157 265   100     589 };
158             };
159             # has 'headers.transform_seed' => sub { random_bytes(32) };
160             # has 'headers.transform_rounds' => 100_000;
161             # has 'headers.inner_random_stream_key' => sub { random_bytes(32) }; # 64 ?
162             # has 'headers.inner_random_stream_id' => STREAM_ID_CHACHA20;
163 59 50   59 1 878 # has 'headers.public_custom_data' => {};
164 59 50   59 1 323  
  59 50       233  
165 59 50 33 59 1 150 # META
  59 50       275  
  59         197  
166 59 50 33 59 1 155 has 'meta.generator' => '', coerce => \&to_string;
  59 50       225  
  59         325  
167 59 50 33 59 1 147 has 'meta.header_hash' => '', coerce => \&to_string;
  59 50       163  
  59         6662  
168 59 50 50 59 1 165 has 'meta.database_name' => '', coerce => \&to_string;
  59 50       208  
  59         259  
169 59 50 33 59 1 163 has 'meta.database_name_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       171  
  59         3653  
170 59 50 50 59 1 138 has 'meta.database_description' => '', coerce => \&to_string;
  59 50       178  
  59         212  
171 59 50 33 59 1 158 has 'meta.database_description_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       187  
  59         3919  
172 59 50 50 59 1 160 has 'meta.default_username' => '', coerce => \&to_string;
  59 50       162  
  59         325  
173 59 50 33 59 1 147 has 'meta.default_username_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       160  
  59         231  
174 59 50 33 59 1 154 has 'meta.maintenance_history_days' => HISTORY_DEFAULT_MAX_AGE, coerce => \&to_number;
  59 50       216  
  59         3455  
175 59 50 50 59 1 155 has 'meta.color' => '', coerce => \&to_string;
  59 50       206  
  59         245  
176 59 50 33     178 has 'meta.master_key_changed' => sub { gmtime }, coerce => \&to_time;
  59         149  
177 59 50 33 220 1 138 has 'meta.master_key_change_rec' => -1, coerce => \&to_number;
  220         673  
178 220 50   66 1 551 has 'meta.master_key_change_force' => -1, coerce => \&to_number;
  66 50       1288  
179 220 100 100 66 1 487 # has 'meta.memory_protection' => {};
  66 50       157  
  66         596  
180 66 100 66 59 1 171 has 'meta.custom_icons' => [];
  66 50       193  
  59         257  
181 66 50 66 59 1 153 has 'meta.recycle_bin_enabled' => true, coerce => \&to_bool;
  59 50       167  
  59         3572  
182 59 50 50 59 1 145 has 'meta.recycle_bin_uuid' => UUID_NULL, coerce => \&to_uuid;
  59 50       180  
  59         238  
183 59 50 33 59 1 173 has 'meta.recycle_bin_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       164  
  59         3453  
184 59 50 50 59 1 129 has 'meta.entry_templates_group' => UUID_NULL, coerce => \&to_uuid;
  59 50       157  
  59         209  
185 59 50 33 61 1 150 has 'meta.entry_templates_group_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       139  
  61         277  
186 59 50 33 61 1 161 has 'meta.last_selected_group' => UUID_NULL, coerce => \&to_uuid;
  61 50       176  
  61         259  
187 61 50 66 59 1 160 has 'meta.last_top_visible_group' => UUID_NULL, coerce => \&to_uuid;
  61 50       165  
  59         206  
188 61 50 66     129 has 'meta.history_max_items' => HISTORY_DEFAULT_MAX_ITEMS, coerce => \&to_number;
  59         155  
189 59   50     130 has 'meta.history_max_size' => HISTORY_DEFAULT_MAX_SIZE, coerce => \&to_number;
190             has 'meta.settings_changed' => sub { gmtime }, coerce => \&to_time;
191 59 50   59 1 3633 # has 'meta.binaries' => {};
192 59 50   59 1 195 # has 'meta.custom_data' => {};
  59 50       571  
193 59 50 33 59 1 179  
  59 50       153  
  59         516  
194 59 50 33 59 1 133 has 'memory_protection.protect_title' => false, coerce => \&to_bool;
  59 50       157  
  59         531  
195 59 50 33 59 1 124 has 'memory_protection.protect_username' => false, coerce => \&to_bool;
  59 50       155  
  59         490  
196 59 50 33     156 has 'memory_protection.protect_password' => true, coerce => \&to_bool;
  59         160  
197 59   33     144 has 'memory_protection.protect_url' => false, coerce => \&to_bool;
198             has 'memory_protection.protect_notes' => false, coerce => \&to_bool;
199             # has 'memory_protection.auto_enable_visual_hiding' => false;
200              
201             my @ATTRS = (
202             HEADER_TRANSFORM_SEED,
203             HEADER_TRANSFORM_ROUNDS,
204             HEADER_INNER_RANDOM_STREAM_KEY,
205             HEADER_INNER_RANDOM_STREAM_ID,
206             HEADER_PUBLIC_CUSTOM_DATA,
207             );
208             sub _set_nonlazy_attributes {
209 59     59   152 my $self = shift;
210 59         250 $self->$_ for list_attributes(ref $self), @ATTRS;
211             }
212              
213              
214             sub memory_protection {
215 384     384 1 471 my $self = shift;
216 384 50 66     805 $self->{meta}{memory_protection} = shift if @_ == 1 && is_plain_hashref($_[0]);
217 384 100 100     1802 return $self->{meta}{memory_protection} //= {} if !@_;
218              
219 89         110 my $string_key = shift;
220 89         163 my $key = 'protect_' . lc($string_key);
221              
222 89 50       152 $self->meta->{memory_protection}{$key} = shift if @_;
223 89         141 $self->meta->{memory_protection}{$key};
224             }
225              
226              
227             sub minimum_version {
228 119     119 1 288 my $self = shift;
229              
230             return KDBX_VERSION_4_1 if any {
231             nonempty $_->{last_modification_time}
232 119 50   3   562 } values %{$self->custom_data};
  3         11  
  119         378  
233              
234             return KDBX_VERSION_4_1 if any {
235             nonempty $_->{name} || nonempty $_->{last_modification_time}
236 119 100 100 11   557 } @{$self->custom_icons};
  11         52  
  119         264  
237              
238             return KDBX_VERSION_4_1 if $self->groups->next(sub {
239             nonempty $_->previous_parent_group ||
240             nonempty $_->tags ||
241 156   100 156   364 (any { nonempty $_->{last_modification_time} } values %{$_->custom_data})
242 117 100       506 });
243              
244             return KDBX_VERSION_4_1 if $self->entries(history => 1)->next(sub {
245             nonempty $_->previous_parent_group ||
246             (defined $_->quality_check && !$_->quality_check) ||
247 27   100 27   210 (any { nonempty $_->{last_modification_time} } values %{$_->custom_data})
248 114 100       1150 });
249              
250 111 100       1459 return KDBX_VERSION_4_0 if $self->kdf->uuid ne KDF_UUID_AES;
251              
252 101 100       439 return KDBX_VERSION_4_0 if nonempty $self->public_custom_data;
253              
254             return KDBX_VERSION_4_0 if $self->objects->next(sub {
255 146     146   385 nonempty $_->custom_data
256 97 100       355 });
257              
258 86         998 return KDBX_VERSION_3_1;
259             }
260              
261             ##############################################################################
262              
263              
264             sub root {
265 664     664 1 62880 my $self = shift;
266 664 100       1259 if (@_) {
267 21         159 $self->{root} = $self->_wrap_group(@_);
268 21         67 $self->{root}->kdbx($self);
269             }
270 664   66     1550 $self->{root} //= $self->_implicit_root;
271 664         1515 return $self->_wrap_group($self->{root});
272             }
273              
274             # Called by File::KeePass::KDBX so that a File::KDBX an be treated as a File::KDBX::Group in that both types
275             # can have subgroups. File::KDBX already has a `groups' method that does something different from the
276             # File::KDBX::Groups `groups' method.
277             sub _kpx_groups {
278 0     0   0 my $self = shift;
279 0 0       0 return [] if !$self->{root};
280 0 0       0 return $self->_has_implicit_root ? $self->root->groups : [$self->root];
281             }
282              
283             sub _has_implicit_root {
284 3     3   10 my $self = shift;
285 3         6 my $root = $self->root;
286 3         9 my $temp = __PACKAGE__->_implicit_root;
287             # If an implicit root group has been changed in any significant way, it is no longer implicit.
288             return $root->name eq $temp->name &&
289             $root->is_expanded ^ $temp->is_expanded &&
290             $root->notes eq $temp->notes &&
291             !@{$root->entries} &&
292             !defined $root->custom_icon_uuid &&
293 3   33     10 !keys %{$root->custom_data} &&
294             $root->icon_id == $temp->icon_id &&
295             $root->expires ^ $temp->expires &&
296             $root->default_auto_type_sequence eq $temp->default_auto_type_sequence &&
297             !defined $root->enable_auto_type &&
298             !defined $root->enable_searching;
299             }
300              
301             sub _implicit_root {
302 62     62   127 my $self = shift;
303 62         4219 require File::KDBX::Group;
304 62 100       253 return File::KDBX::Group->new(
305             name => 'Root',
306             is_expanded => true,
307             notes => 'Added as an implicit root group by '.__PACKAGE__.'.',
308             ref $self ? (kdbx => $self) : (),
309             );
310             }
311              
312              
313             sub trace_lineage {
314 0     0 1 0 my $self = shift;
315 0         0 my $object = shift;
316 0         0 return $object->lineage(@_);
317             }
318              
319             sub _trace_lineage {
320 213     213   332 my $self = shift;
321 213         244 my $object = shift;
322 213         325 my @lineage = @_;
323              
324 213 100       445 push @lineage, $self->root if !@lineage;
325 213 50       419 my $base = $lineage[-1] or return [];
326              
327 213         352 my $uuid = $object->uuid;
328 213 100   170   561 return \@lineage if any { $_->uuid eq $uuid } @{$base->groups}, @{$base->entries};
  170         288  
  213         405  
  213         370  
329              
330 212         412 for my $subgroup (@{$base->groups}) {
  212         330  
331 90         178 my $result = $self->_trace_lineage($object, @lineage, $subgroup);
332 90 50       312 return $result if $result;
333             }
334             }
335              
336              
337             sub recycle_bin {
338 6     6 1 9 my $self = shift;
339 6 50       10 if (my $group = shift) {
340 0         0 $self->recycle_bin_uuid($group->uuid);
341 0         0 return $group;
342             }
343 6         7 my $group;
344 6         11 my $uuid = $self->recycle_bin_uuid;
345 6 100       17 $group = $self->groups->grep(uuid => $uuid)->next if $uuid ne UUID_NULL;
346 6 100 66     49 if (!$group && $self->recycle_bin_enabled) {
347 1         17 $group = $self->add_group(
348             name => 'Recycle Bin',
349             icon_id => ICON_TRASHCAN_FULL,
350             enable_auto_type => false,
351             enable_searching => false,
352             );
353 1         4 $self->recycle_bin_uuid($group->uuid);
354             }
355 6         15 return $group;
356             }
357              
358              
359             sub entry_templates {
360 0     0 1 0 my $self = shift;
361 0 0       0 if (my $group = shift) {
362 0         0 $self->entry_templates_group($group->uuid);
363 0         0 return $group;
364             }
365 0         0 my $uuid = $self->entry_templates_group;
366 0 0       0 return if $uuid eq UUID_NULL;
367 0         0 return $self->groups->grep(uuid => $uuid)->next;
368             }
369              
370              
371             sub last_selected {
372 0     0 1 0 my $self = shift;
373 0 0       0 if (my $group = shift) {
374 0         0 $self->last_selected_group($group->uuid);
375 0         0 return $group;
376             }
377 0         0 my $uuid = $self->last_selected_group;
378 0 0       0 return if $uuid eq UUID_NULL;
379 0         0 return $self->groups->grep(uuid => $uuid)->next;
380             }
381              
382              
383             sub last_top_visible {
384 0     0 1 0 my $self = shift;
385 0 0       0 if (my $group = shift) {
386 0         0 $self->last_top_visible_group($group->uuid);
387 0         0 return $group;
388             }
389 0         0 my $uuid = $self->last_top_visible_group;
390 0 0       0 return if $uuid eq UUID_NULL;
391 0         0 return $self->groups->grep(uuid => $uuid)->next;
392             }
393              
394             ##############################################################################
395              
396              
397             sub add_group {
398 15     15 1 60 my $self = shift;
399 15 50       50 my $group = @_ % 2 == 1 ? shift : undef;
400 15         42 my %args = @_;
401              
402             # find the right group to add the group to
403 15   33     54 my $parent = delete $args{group} // $self->root;
404 15 50       49 $parent = $self->groups->grep({uuid => $parent})->next if !ref $parent;
405 15 50       33 $parent or throw 'Invalid group';
406              
407 15 50       69 return $parent->add_group(defined $group ? $group : (), %args, kdbx => $self);
408             }
409              
410             sub _wrap_group {
411 685     685   851 my $self = shift;
412 685         733 my $group = shift;
413 685         2819 require File::KDBX::Group;
414 685         2081 return File::KDBX::Group->wrap($group, $self);
415             }
416              
417              
418             sub groups {
419 133     133 1 226 my $self = shift;
420 133 50       505 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
421 133   33     500 my $base = delete $args{base} // $self->root;
422              
423 133         457 return $base->all_groups(%args);
424             }
425              
426             ##############################################################################
427              
428              
429             sub add_entry {
430 20     20 1 955 my $self = shift;
431 20 100       71 my $entry = @_ % 2 == 1 ? shift : undef;
432 20         74 my %args = @_;
433              
434             # find the right group to add the entry to
435 20   33     89 my $parent = delete $args{group} // $self->root;
436 20 50       67 $parent = $self->groups->grep({uuid => $parent})->next if !ref $parent;
437 20 50       52 $parent or throw 'Invalid group';
438              
439 20 100       112 return $parent->add_entry(defined $entry ? $entry : (), %args, kdbx => $self);
440             }
441              
442             sub _wrap_entry {
443 0     0   0 my $self = shift;
444 0         0 my $entry = shift;
445 0         0 require File::KDBX::Entry;
446 0         0 return File::KDBX::Entry->wrap($entry, $self);
447             }
448              
449              
450             sub entries {
451 204     204 1 1397 my $self = shift;
452 204 50       743 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
453 204   33     760 my $base = delete $args{base} // $self->root;
454              
455 204         1099 return $base->all_entries(%args);
456             }
457              
458             ##############################################################################
459              
460              
461             sub objects {
462 106     106 1 185 my $self = shift;
463 106 50       316 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
464 106   33     413 my $base = delete $args{base} // $self->root;
465              
466 106         390 return $base->all_objects(%args);
467             }
468              
469 0     0   0 sub __iter__ { $_[0]->objects }
470              
471             ##############################################################################
472              
473              
474             sub custom_icon {
475 0     0 1 0 my $self = shift;
476 0 0       0 my %args = @_ == 2 ? (uuid => shift, data => shift)
    0          
477             : @_ % 2 == 1 ? (uuid => shift, @_) : @_;
478              
479 0 0 0     0 if (!$args{uuid} && !$args{data}) {
480 0         0 my %standard = (uuid => 1, data => 1, name => 1, last_modification_time => 1);
481 0         0 my @other_keys = grep { !$standard{$_} } keys %args;
  0         0  
482 0 0       0 if (@other_keys == 1) {
483 0         0 my $key = $args{key} = $other_keys[0];
484 0         0 $args{data} = delete $args{$key};
485             }
486             }
487              
488 0 0       0 my $uuid = $args{uuid} or throw 'Must provide a custom icon UUID to access';
489 0   0 0   0 my $icon = (first { $_->{uuid} eq $uuid } @{$self->custom_icons}) // do {
  0         0  
  0         0  
490 0         0 push @{$self->custom_icons}, my $i = { uuid => $uuid };
  0         0  
491 0         0 $i;
492             };
493              
494 0         0 my $fields = \%args;
495 0 0       0 $fields = $args{data} if is_plain_hashref($args{data});
496              
497 0         0 while (my ($field, $value) = each %$fields) {
498 0         0 $icon->{$field} = $value;
499             }
500 0         0 return $icon;
501             }
502              
503              
504             sub custom_icon_data {
505 5     5 1 8 my $self = shift;
506 5   100     14 my $uuid = shift // return;
507 4 50   4   13 my $icon = first { $_->{uuid} eq $uuid } @{$self->custom_icons} or return;
  4         12  
  4         8  
508 4         18 return $icon->{data};
509             }
510              
511              
512             sub add_custom_icon {
513 9     9 1 17 my $self = shift;
514 9 50       41 my %args = @_ % 2 == 1 ? (data => shift, @_) : @_;
515              
516 9 50       24 defined $args{data} or throw 'Must provide image data';
517              
518 9   33     38 my $uuid = $args{uuid} // generate_uuid;
519 9         17 push @{$self->custom_icons}, {
520             @_,
521             uuid => $uuid,
522             data => $args{data},
523 9         18 };
524 9         22 return $uuid;
525             }
526              
527              
528             sub remove_custom_icon {
529 5     5 1 10 my $self = shift;
530 5         6 my $uuid = shift;
531 5         8 my @deleted;
532 5 100       9 @{$self->custom_icons} = grep { $_->{uuid} eq $uuid ? do { push @deleted, $_; 0 } : 1 }
  12         28  
  5         12  
  5         10  
533 5         8 @{$self->custom_icons};
  5         10  
534 5 50       24 $self->add_deleted_object($uuid) if @deleted;
535 5         16 return @deleted;
536             }
537              
538             ##############################################################################
539              
540              
541             sub custom_data {
542 120     120 1 216 my $self = shift;
543 120 50 33     369 $self->{meta}{custom_data} = shift if @_ == 1 && is_plain_hashref($_[0]);
544 120 100 100     1003 return $self->{meta}{custom_data} //= {} if !@_;
545              
546 1 0       8 my %args = @_ == 2 ? (key => shift, value => shift)
    50          
547             : @_ % 2 == 1 ? (key => shift, @_) : @_;
548              
549 1 0 33     4 if (!$args{key} && !$args{value}) {
550 0         0 my %standard = (key => 1, value => 1, last_modification_time => 1);
551 0         0 my @other_keys = grep { !$standard{$_} } keys %args;
  0         0  
552 0 0       0 if (@other_keys == 1) {
553 0         0 my $key = $args{key} = $other_keys[0];
554 0         0 $args{value} = delete $args{$key};
555             }
556             }
557              
558 1 50       3 my $key = $args{key} or throw 'Must provide a custom_data key to access';
559              
560 1 50       5 return $self->{meta}{custom_data}{$key} = $args{value} if is_plain_hashref($args{value});
561              
562 1         6 while (my ($field, $value) = each %args) {
563 2         8 $self->{meta}{custom_data}{$key}{$field} = $value;
564             }
565 1         4 return $self->{meta}{custom_data}{$key};
566             }
567              
568              
569             sub custom_data_value {
570 0     0 1 0 my $self = shift;
571 0   0     0 my $data = $self->custom_data(@_) // return;
572 0         0 return $data->{value};
573             }
574              
575              
576             sub public_custom_data {
577 172     172 1 1347 my $self = shift;
578 172 50 33     427 $self->{headers}{+HEADER_PUBLIC_CUSTOM_DATA} = shift if @_ == 1 && is_plain_hashref($_[0]);
579 172 50 100     904 return $self->{headers}{+HEADER_PUBLIC_CUSTOM_DATA} //= {} if !@_;
580              
581 0 0       0 my $key = shift or throw 'Must provide a public_custom_data key to access';
582 0 0       0 $self->{headers}{+HEADER_PUBLIC_CUSTOM_DATA}{$key} = shift if @_;
583 0         0 return $self->{headers}{+HEADER_PUBLIC_CUSTOM_DATA}{$key};
584             }
585              
586             ##############################################################################
587              
588             # TODO
589              
590             # sub merge_to {
591             # my $self = shift;
592             # my $other = shift;
593             # my %options = @_; # prefer_old / prefer_new
594             # $other->merge_from($self);
595             # }
596              
597             # sub merge_from {
598             # my $self = shift;
599             # my $other = shift;
600              
601             # die 'Not implemented';
602             # }
603              
604              
605             sub add_deleted_object {
606 11     11 1 15 my $self = shift;
607 11         15 my $uuid = shift;
608              
609             # ignore null and meta stream UUIDs
610 11 50 33     40 return if $uuid eq UUID_NULL || $uuid eq '0' x 16;
611              
612 11         34 $self->deleted_objects->{$uuid} = {
613             uuid => $uuid,
614             deletion_time => scalar gmtime,
615             };
616             }
617              
618              
619             sub remove_deleted_object {
620 48     48 1 66 my $self = shift;
621 48         74 my $uuid = shift;
622 48         113 delete $self->deleted_objects->{$uuid};
623             }
624              
625              
626             sub clear_deleted_objects {
627 0     0 1 0 my $self = shift;
628 0         0 %{$self->deleted_objects} = ();
  0         0  
629             }
630              
631             ##############################################################################
632              
633              
634             sub resolve_reference {
635 18     18 1 6989 my $self = shift;
636 18   50     43 my $wanted = shift // return;
637 18         27 my $search_in = shift;
638 18         22 my $text = shift;
639              
640 18 100       54 if (!defined $text) {
641 17         82 $wanted =~ s/^\{REF:([^\}]+)\}$/$1/i;
642 17         91 ($wanted, $search_in, $text) = $wanted =~ /^([TUPANI])\@([TUPANIO]):(.*)$/i;
643             }
644 18 50 66     110 $wanted && $search_in && nonempty($text) or return;
      66        
645              
646 17         102 my %fields = (
647             T => 'expand_title',
648             U => 'expand_username',
649             P => 'expand_password',
650             A => 'expand_url',
651             N => 'expand_notes',
652             I => 'uuid',
653             O => 'other_strings',
654             );
655 17 50       45 $wanted = $fields{$wanted} or return;
656 17 50       44 $search_in = $fields{$search_in} or return;
657              
658 17 100       72 my $query = $search_in eq 'uuid' ? query($search_in => uuid($text))
659             : simple_expression_query($text, '=~', $search_in);
660              
661 17         63 my $entry = $self->entries->grep($query)->next;
662 17 100       260 $entry or return;
663              
664 15         52 return $entry->$wanted;
665             }
666              
667             our %PLACEHOLDERS = (
668             # 'PLACEHOLDER' => sub { my ($entry, $arg) = @_; ... };
669             'TITLE' => sub { $_[0]->expand_title },
670             'USERNAME' => sub { $_[0]->expand_username },
671             'PASSWORD' => sub { $_[0]->expand_password },
672             'NOTES' => sub { $_[0]->expand_notes },
673             'S:' => sub { $_[0]->string_value($_[1]) },
674             'URL' => sub { $_[0]->expand_url },
675             'URL:RMVSCM' => sub { local $_ = $_[0]->url; s!^[^:/\?\#]+://!!; $_ },
676             'URL:WITHOUTSCHEME' => sub { local $_ = $_[0]->url; s!^[^:/\?\#]+://!!; $_ },
677             'URL:SCM' => sub { (split_url($_[0]->url))[0] },
678             'URL:SCHEME' => sub { (split_url($_[0]->url))[0] }, # non-standard
679             'URL:HOST' => sub { (split_url($_[0]->url))[2] },
680             'URL:PORT' => sub { (split_url($_[0]->url))[3] },
681             'URL:PATH' => sub { (split_url($_[0]->url))[4] },
682             'URL:QUERY' => sub { (split_url($_[0]->url))[5] },
683             'URL:HASH' => sub { (split_url($_[0]->url))[6] }, # non-standard
684             'URL:FRAGMENT' => sub { (split_url($_[0]->url))[6] }, # non-standard
685             'URL:USERINFO' => sub { (split_url($_[0]->url))[1] },
686             'URL:USERNAME' => sub { (split_url($_[0]->url))[7] },
687             'URL:PASSWORD' => sub { (split_url($_[0]->url))[8] },
688             'UUID' => sub { local $_ = format_uuid($_[0]->uuid); s/-//g; $_ },
689             'REF:' => sub { $_[0]->kdbx->resolve_reference($_[1]) },
690             'INTERNETEXPLORER' => sub { load_optional('IPC::Cmd'); IPC::Cmd::can_run('iexplore') },
691             'FIREFOX' => sub { load_optional('IPC::Cmd'); IPC::Cmd::can_run('firefox') },
692             'GOOGLECHROME' => sub { load_optional('IPC::Cmd'); IPC::Cmd::can_run('google-chrome') },
693             'OPERA' => sub { load_optional('IPC::Cmd'); IPC::Cmd::can_run('opera') },
694             'SAFARI' => sub { load_optional('IPC::Cmd'); IPC::Cmd::can_run('safari') },
695             'APPDIR' => sub { load_optional('FindBin'); $FindBin::Bin },
696             'GROUP' => sub { my $p = $_[0]->group; $p ? $p->name : undef },
697             'GROUP_PATH' => sub { $_[0]->path },
698             'GROUP_NOTES' => sub { my $p = $_[0]->group; $p ? $p->notes : undef },
699             # 'GROUP_SEL'
700             # 'GROUP_SEL_PATH'
701             # 'GROUP_SEL_NOTES'
702             # 'DB_PATH'
703             # 'DB_DIR'
704             # 'DB_NAME'
705             # 'DB_BASENAME'
706             # 'DB_EXT'
707             'ENV:' => sub { $ENV{$_[1]} },
708             'ENV_DIRSEP' => sub { load_optional('File::Spec')->catfile('', '') },
709             'ENV_PROGRAMFILES_X86' => sub { $ENV{'ProgramFiles(x86)'} || $ENV{'ProgramFiles'} },
710             # 'T-REPLACE-RX:'
711             # 'T-CONV:'
712             'DT_SIMPLE' => sub { localtime->strftime('%Y%m%d%H%M%S') },
713             'DT_YEAR' => sub { localtime->strftime('%Y') },
714             'DT_MONTH' => sub { localtime->strftime('%m') },
715             'DT_DAY' => sub { localtime->strftime('%d') },
716             'DT_HOUR' => sub { localtime->strftime('%H') },
717             'DT_MINUTE' => sub { localtime->strftime('%M') },
718             'DT_SECOND' => sub { localtime->strftime('%S') },
719             'DT_UTC_SIMPLE' => sub { gmtime->strftime('%Y%m%d%H%M%S') },
720             'DT_UTC_YEAR' => sub { gmtime->strftime('%Y') },
721             'DT_UTC_MONTH' => sub { gmtime->strftime('%m') },
722             'DT_UTC_DAY' => sub { gmtime->strftime('%d') },
723             'DT_UTC_HOUR' => sub { gmtime->strftime('%H') },
724             'DT_UTC_MINUTE' => sub { gmtime->strftime('%M') },
725             'DT_UTC_SECOND' => sub { gmtime->strftime('%S') },
726             # 'PICKCHARS'
727             # 'PICKCHARS:'
728             # 'PICKFIELD'
729             # 'NEWPASSWORD'
730             # 'NEWPASSWORD:'
731             # 'PASSWORD_ENC'
732             'HMACOTP' => sub { $_[0]->hmac_otp },
733             'TIMEOTP' => sub { $_[0]->time_otp },
734             'C:' => sub { '' }, # comment
735             # 'BASE'
736             # 'BASE:'
737             # 'CLIPBOARD'
738             # 'CLIPBOARD-SET:'
739             # 'CMD:'
740             );
741              
742             ##############################################################################
743              
744              
745             sub _safe {
746 50     50   103 my $self = shift;
747 50 100       154 $SAFE{$self} = shift if @_;
748 50         254 $SAFE{$self};
749             }
750              
751 90     90   464 sub _remove_safe { delete $SAFE{$_[0]} }
752              
753             sub lock {
754 0     0 1 0 my $self = shift;
755              
756 0 0       0 $self->_safe and return $self;
757              
758 0         0 my @strings;
759              
760             $self->entries(history => 1)->each(sub {
761 0     0   0 push @strings, grep { $_->{protect} } values %{$_->strings}, values %{$_->binaries};
  0         0  
  0         0  
  0         0  
762 0         0 });
763              
764 0         0 $self->_safe(File::KDBX::Safe->new(\@strings));
765              
766 0         0 return $self;
767             }
768              
769              
770             sub unlock {
771 19     19 1 10431 my $self = shift;
772 19 100       70 my $safe = $self->_safe or return $self;
773              
774 4         20 $safe->unlock;
775 4         15 $self->_remove_safe;
776              
777 4         15 return $self;
778             }
779              
780              
781             sub unlock_scoped {
782 15 50   15 1 53 throw 'Programmer error: Cannot call unlock_scoped in void context' if !defined wantarray;
783 15         37 my $self = shift;
784 15 50       58 return if !$self->is_locked;
785 0         0 require Scope::Guard;
786 0     0   0 my $guard = Scope::Guard->new(sub { $self->lock });
  0         0  
787 0         0 $self->unlock;
788 0         0 return $guard;
789             }
790              
791              
792             sub peek {
793 4     4 1 9 my $self = shift;
794 4         6 my $string = shift;
795 4 50       11 my $safe = $self->_safe or return;
796 4         17 return $safe->peek($string);
797             }
798              
799              
800 15     15 1 63 sub is_locked { !!$_[0]->_safe }
801              
802             ##############################################################################
803              
804             # sub check {
805             # - Fixer tool. Can repair inconsistencies, including:
806             # - Orphaned binaries... not really a thing anymore since we now distribute binaries amongst entries
807             # - Unused custom icons (OFF, data loss)
808             # - Duplicate icons
809             # - All data types are valid
810             # - date times are correct
811             # - boolean fields
812             # - All UUIDs refer to things that exist
813             # - previous parent group
814             # - recycle bin
815             # - last selected group
816             # - last visible group
817             # - Enforce history size limits (ON)
818             # - Check headers/meta (ON)
819             # - Duplicate deleted objects (ON)
820             # - Duplicate window associations (OFF)
821             # - Header UUIDs match known ciphers/KDFs?
822             # }
823              
824              
825             sub remove_empty_groups {
826 1     1 1 6 my $self = shift;
827 1         2 my @removed;
828             $self->groups(algorithm => 'dfs')
829             ->where(-true => 'is_empty')
830 1     3   4 ->each(sub { push @removed, $_->remove });
  3         8  
831 1         95 return @removed;
832             }
833              
834              
835             sub remove_unused_icons {
836 2     2 1 4 my $self = shift;
837 2         3 my %icons = map { $_->{uuid} => 0 } @{$self->custom_icons};
  3         16  
  2         5  
838              
839 2   100 6   7 $self->objects->each(sub { ++$icons{$_->custom_icon_uuid // ''} });
  6         14  
840              
841 2         20 my @removed;
842 2         5 push @removed, $self->remove_custom_icon($_) for grep { $icons{$_} == 0 } keys %icons;
  5         14  
843 2         12 return @removed;
844             }
845              
846              
847             sub remove_duplicate_icons {
848 1     1 1 7 my $self = shift;
849              
850 1         3 my %seen;
851             my %dup;
852 1         2 for my $icon (@{$self->custom_icons}) {
  1         2  
853 3         18 my $digest = digest_data('SHA256', $icon->{data});
854 3 100       8 if (my $other = $seen{$digest}) {
855 1         5 $dup{$icon->{uuid}} = $other->{uuid};
856             }
857             else {
858 2         6 $seen{$digest} = $icon;
859             }
860             }
861              
862 1         2 my @removed;
863 1         7 while (my ($old_uuid, $new_uuid) = each %dup) {
864             $self->objects
865             ->where(custom_icon_uuid => $old_uuid)
866 1     1   4 ->each(sub { $_->custom_icon_uuid($new_uuid) });
  1         4  
867 1         17 push @removed, $self->remove_custom_icon($old_uuid);
868             }
869 1         7 return @removed;
870             }
871              
872              
873             sub prune_history {
874 2     2 1 17 my $self = shift;
875 2         5 my %args = @_;
876              
877 2   33     8 my $max_items = $args{max_items} // $self->history_max_items // HISTORY_DEFAULT_MAX_ITEMS;
      50        
878 2   33     7 my $max_size = $args{max_size} // $self->history_max_size // HISTORY_DEFAULT_MAX_SIZE;
      50        
879 2   33     6 my $max_age = $args{max_age} // $self->maintenance_history_days // HISTORY_DEFAULT_MAX_AGE;
      50        
880              
881 2         4 my @removed;
882             $self->entries->each(sub {
883 2     2   9 push @removed, $_->prune_history(
884             max_items => $max_items,
885             max_size => $max_size,
886             max_age => $max_age,
887             );
888 2         4 });
889 2         27 return @removed;
890             }
891              
892              
893             sub randomize_seeds {
894 14     14 1 30 my $self = shift;
895 14         76 $self->encryption_iv(random_bytes(16));
896 14         42 $self->inner_random_stream_key(random_bytes(64));
897 14         51 $self->master_seed(random_bytes(32));
898 14         41 $self->stream_start_bytes(random_bytes(32));
899 14         38 $self->transform_seed(random_bytes(32));
900             }
901              
902             ##############################################################################
903              
904              
905             sub key {
906 36     36 1 65 my $self = shift;
907 36 50       221 $KEYS{$self} = File::KDBX::Key->new(@_) if @_;
908 36         109 $KEYS{$self};
909             }
910              
911              
912             sub composite_key {
913 45     45 1 102 my $self = shift;
914 45         2175 require File::KDBX::Key::Composite;
915 45         390 return File::KDBX::Key::Composite->new(@_);
916             }
917              
918              
919             sub kdf {
920 173     173 1 2751 my $self = shift;
921 173 50       554 my %args = @_ % 2 == 1 ? (params => shift, @_) : @_;
922              
923 173         289 my $params = $args{params};
924 173   50     627 my $compat = $args{compatible} // 1;
925              
926 173   33     675 $params //= $self->kdf_parameters;
927 173 50       268 $params = {%{$params || {}}};
  173         1044  
928              
929 173 50 33     728 if (empty $params || !defined $params->{+KDF_PARAM_UUID}) {
930 0         0 $params->{+KDF_PARAM_UUID} = KDF_UUID_AES;
931             }
932 173 100       595 if ($params->{+KDF_PARAM_UUID} eq KDF_UUID_AES) {
933             # AES_CHALLENGE_RESPONSE is equivalent to AES if there are no challenge-response keys, and since
934             # non-KeePassXC implementations don't support challenge-response keys anyway, there's no problem with
935             # always using AES_CHALLENGE_RESPONSE for all KDBX4+ databases.
936             # For compatibility, we should not *write* AES_CHALLENGE_RESPONSE, but the dumper handles that.
937 155 100       401 if ($self->version >= KDBX_VERSION_4_0) {
938 19         48 $params->{+KDF_PARAM_UUID} = KDF_UUID_AES_CHALLENGE_RESPONSE;
939             }
940 155   33     398 $params->{+KDF_PARAM_AES_SEED} //= $self->transform_seed;
941 155   33     414 $params->{+KDF_PARAM_AES_ROUNDS} //= $self->transform_rounds;
942             }
943              
944 173         5075 require File::KDBX::KDF;
945 173         1305 return File::KDBX::KDF->new(%$params);
946             }
947              
948             sub transform_seed {
949 81     81 1 509 my $self = shift;
950             $self->headers->{+HEADER_TRANSFORM_SEED} =
951 81 100       201 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_SEED} = shift if @_;
952             $self->headers->{+HEADER_TRANSFORM_SEED} =
953 81   33     198 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_SEED} //= random_bytes(32);
954             }
955              
956             sub transform_rounds {
957 67     67 1 127 my $self = shift;
958             $self->headers->{+HEADER_TRANSFORM_ROUNDS} =
959 67 50       176 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_ROUNDS} = shift if @_;
960             $self->headers->{+HEADER_TRANSFORM_ROUNDS} =
961 67   50     145 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_ROUNDS} //= 100_000;
962             }
963              
964              
965             sub cipher {
966 39     39 1 142 my $self = shift;
967 39         121 my %args = @_;
968              
969 39   33     277 $args{uuid} //= $self->headers->{+HEADER_CIPHER_ID};
970 39   33     190 $args{iv} //= $self->headers->{+HEADER_ENCRYPTION_IV};
971              
972 39         141 require File::KDBX::Cipher;
973 39         191 return File::KDBX::Cipher->new(%args);
974             }
975              
976              
977             sub random_stream {
978 18     18 1 40 my $self = shift;
979 18         41 my %args = @_;
980              
981 18   33     225 $args{stream_id} //= delete $args{id} // $self->inner_random_stream_id;
      33        
982 18   33     111 $args{key} //= $self->inner_random_stream_key;
983              
984 18         86 require File::KDBX::Cipher;
985 18         153 File::KDBX::Cipher->new(%args);
986             }
987              
988             sub inner_random_stream_id {
989 85     85 1 870 my $self = shift;
990             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_ID}
991 85 100       216 = $self->headers->{+HEADER_INNER_RANDOM_STREAM_ID} = shift if @_;
992             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_ID}
993 85   66     194 //= $self->headers->{+HEADER_INNER_RANDOM_STREAM_ID} //= do {
      66        
994 59         195 my $version = $self->minimum_version;
995 59 50       480 $version < KDBX_VERSION_4_0 ? STREAM_ID_SALSA20 : STREAM_ID_CHACHA20;
996             };
997             }
998              
999             sub inner_random_stream_key {
1000 107     107 1 305 my $self = shift;
1001 107 100       268 if (@_) {
1002             # These are probably the same SvPV so erasing one will CoW, but erasing the second should do the
1003             # trick anyway.
1004 22         56 erase \$self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY};
1005 22         57 erase \$self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY};
1006             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY}
1007 22         60 = $self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY} = shift;
1008             }
1009             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY}
1010 107   66     269 //= $self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY} //= random_bytes(64); # 32
      66        
1011             }
1012              
1013             #########################################################################################
1014              
1015             sub _handle_signal {
1016 68     68   99 my $self = shift;
1017 68         94 my $object = shift;
1018 68         90 my $type = shift;
1019              
1020 68         352 my %handlers = (
1021             'entry.added' => \&_handle_object_added,
1022             'group.added' => \&_handle_object_added,
1023             'entry.removed' => \&_handle_object_removed,
1024             'group.removed' => \&_handle_object_removed,
1025             'entry.uuid.changed' => \&_handle_entry_uuid_changed,
1026             'group.uuid.changed' => \&_handle_group_uuid_changed,
1027             );
1028 68 50       173 my $handler = $handlers{$type} or return;
1029 68         157 $self->$handler($object, @_);
1030             }
1031              
1032             sub _handle_object_added {
1033 48     48   78 my $self = shift;
1034 48         68 my $object = shift;
1035 48         106 $self->remove_deleted_object($object->uuid);
1036             }
1037              
1038             sub _handle_object_removed {
1039 6     6   9 my $self = shift;
1040 6         9 my $object = shift;
1041 6   50     13 my $old_uuid = $object->{uuid} // return;
1042              
1043 6         12 my $meta = $self->meta;
1044 6 50 50     26 $self->recycle_bin_uuid(UUID_NULL) if $old_uuid eq ($meta->{recycle_bin_uuid} // '');
1045 6 50 50     18 $self->entry_templates_group(UUID_NULL) if $old_uuid eq ($meta->{entry_templates_group} // '');
1046 6 50 50     16 $self->last_selected_group(UUID_NULL) if $old_uuid eq ($meta->{last_selected_group} // '');
1047 6 50 50     16 $self->last_top_visible_group(UUID_NULL) if $old_uuid eq ($meta->{last_top_visible_group} // '');
1048              
1049 6         19 $self->add_deleted_object($old_uuid);
1050             }
1051              
1052             sub _handle_entry_uuid_changed {
1053 13     13   21 my $self = shift;
1054 13         16 my $object = shift;
1055 13         19 my $new_uuid = shift;
1056 13   50     68 my $old_uuid = shift // return;
1057              
1058 13         31 my $old_pretty = format_uuid($old_uuid);
1059 13         28 my $new_pretty = format_uuid($new_uuid);
1060 13         185 my $fieldref_match = qr/\{REF:([TUPANI])\@I:\Q$old_pretty\E\}/is;
1061              
1062             $self->entries->each(sub {
1063 26 50 50 26   84 $_->previous_parent_group($new_uuid) if $old_uuid eq ($_->{previous_parent_group} // '');
1064              
1065 26         30 for my $string (values %{$_->strings}) {
  26         45  
1066 130 100 66     419 next if !defined $string->{value} || $string->{value} !~ $fieldref_match;
1067 1         4 my $txn = $_->begin_work;
1068 1         13 $string->{value} =~ s/$fieldref_match/{REF:$1\@I:$new_pretty}/g;
1069 1         4 $txn->commit;
1070             }
1071 13         35 });
1072             }
1073              
1074             sub _handle_group_uuid_changed {
1075 1     1   3 my $self = shift;
1076 1         2 my $object = shift;
1077 1         3 my $new_uuid = shift;
1078 1   50     3 my $old_uuid = shift // return;
1079              
1080 1         4 my $meta = $self->meta;
1081 1 50 50     6 $self->recycle_bin_uuid($new_uuid) if $old_uuid eq ($meta->{recycle_bin_uuid} // '');
1082 1 50 50     6 $self->entry_templates_group($new_uuid) if $old_uuid eq ($meta->{entry_templates_group} // '');
1083 1 50 50     5 $self->last_selected_group($new_uuid) if $old_uuid eq ($meta->{last_selected_group} // '');
1084 1 50 50     7 $self->last_top_visible_group($new_uuid) if $old_uuid eq ($meta->{last_top_visible_group} // '');
1085              
1086             $self->groups->each(sub {
1087 2 50 50 2   12 $_->last_top_visible_entry($new_uuid) if $old_uuid eq ($_->{last_top_visible_entry} // '');
1088 2 50 50     10 $_->previous_parent_group($new_uuid) if $old_uuid eq ($_->{previous_parent_group} // '');
1089 1         4 });
1090             $self->entries->each(sub {
1091 3 50 50 3   15 $_->previous_parent_group($new_uuid) if $old_uuid eq ($_->{previous_parent_group} // '');
1092 1         9 });
1093             }
1094              
1095             #########################################################################################
1096              
1097              
1098             #########################################################################################
1099              
1100 0     0 0   sub TO_JSON { +{%{$_[0]}} }
  0            
1101              
1102             1;
1103              
1104             __END__