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   625007 use 5.010;
  12         43  
5 12     12   62 use warnings;
  12         21  
  12         332  
6 12     12   55 use strict;
  12         224  
  12         337  
7              
8 12     12   3027 use Crypt::Digest qw(digest_data);
  12         5364  
  12         653  
9 12     12   79 use Crypt::PRNG qw(random_bytes);
  12         19  
  12         435  
10 12     12   2676 use Devel::GlobalDestruction;
  12         3183  
  12         71  
11 12     12   702 use File::KDBX::Constants qw(:all :icon);
  12         20  
  12         9644  
12 12     12   87 use File::KDBX::Error;
  12         20  
  12         550  
13 12     12   4359 use File::KDBX::Safe;
  12         27  
  12         429  
14 12     12   69 use File::KDBX::Util qw(:class :coercion :empty :search :uuid erase simple_expression_query snakify);
  12         20  
  12         3751  
15 12     12   3458 use Hash::Util::FieldHash qw(fieldhashes);
  12         5515  
  12         553  
16 12     12   2013 use List::Util qw(any first);
  12         24  
  12         601  
17 12     12   56 use Ref::Util qw(is_ref is_arrayref is_plain_hashref);
  12         18  
  12         498  
18 12     12   1190 use Scalar::Util qw(blessed);
  12         956  
  12         454  
19 12     12   65 use Time::Piece 1.33;
  12         219  
  12         84  
20 12     12   863 use boolean;
  12         22  
  12         1513  
21 12     12   641 use namespace::clean;
  12         26  
  12         71  
22              
23             our $VERSION = '0.905'; # VERSION
24             our $WARNINGS = 1;
25              
26             fieldhashes \my (%SAFE, %KEYS);
27              
28              
29             sub new {
30 59     59 1 76902 my $class = shift;
31              
32             # copy constructor
33 59 0 33     198 return $_[0]->clone if @_ == 1 && blessed $_[0] && $_[0]->isa($class);
      33        
34              
35 59         124 my $self = bless {}, $class;
36 59         199 $self->init(@_);
37 59 50       173 $self->_set_nonlazy_attributes if empty $self;
38 59         348 return $self;
39             }
40              
41 56 50   56   22552 sub DESTROY { local ($., $@, $!, $^E, $?); !in_global_destruction and $_[0]->reset }
  56         1250  
42              
43              
44             sub init {
45 59     59 1 97 my $self = shift;
46 59         112 my %args = @_;
47              
48 59         153 @$self{keys %args} = values %args;
49              
50 59         98 return $self;
51             }
52              
53              
54             sub reset {
55 86     86 1 511 my $self = shift;
56 86         187 erase $self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY};
57 86         216 erase $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY};
58 86         238 erase $self->{raw};
59 86         2379 %$self = ();
60 86         383 $self->_remove_safe;
61 86         769 return $self;
62             }
63              
64              
65             sub clone {
66 1     1 1 2 my $self = shift;
67 1         3 require Storable;
68 1         30 return Storable::dclone($self);
69             }
70              
71             sub STORABLE_freeze {
72 1     1 0 3 my $self = shift;
73 1         2 my $cloning = shift;
74              
75 1         7 my $copy = {%$self};
76              
77 1   33     30 return '', $copy, $KEYS{$self} // (), $SAFE{$self} // ();
      33        
78             }
79              
80             sub STORABLE_thaw {
81 1     1 0 3 my $self = shift;
82 1         1 my $cloning = shift;
83 1         1 shift;
84 1         2 my $clone = shift;
85 1         1 my $key = shift;
86 1         2 my $safe = shift;
87              
88 1         5 @$self{keys %$clone} = values %$clone;
89 1         6 $KEYS{$self} = $key;
90 1         2 $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   4 $self->objects(history => 1)->each(sub { $_->kdbx($self) });
  3         7  
96             }
97              
98             ##############################################################################
99              
100              
101 9     9 1 15974 sub load { shift->_loader->load(@_) }
102 21     21 1 968 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   55 my $self = shift;
108 30 50       117 $self = $self->new if !ref $self;
109 30         2293 require File::KDBX::Loader;
110 30         155 File::KDBX::Loader->new(kdbx => $self);
111             }
112              
113              
114 2     2 1 1424 sub dump { shift->_dumper->dump(@_) }
115 13     13 1 184 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   30 my $self = shift;
121 15 50       46 $self = $self->new if !ref $self;
122 15         2718 require File::KDBX::Dumper;
123 15         84 File::KDBX::Dumper->new(kdbx => $self);
124             }
125              
126             ##############################################################################
127              
128              
129             sub user_agent_string {
130 15     15 1 74 require Config;
131             sprintf('%s/%s (%s/%s; %s/%s; %s)',
132 15 50   156 1 604 __PACKAGE__, $VERSION, @Config::Config{qw(package version osname osvers archname)});
  156         1106  
133 156 100   177 1 428 }
  177 50       506  
134 156 100 66 605 1 637  
  177 50       415  
  605         1919  
135 177 100 66 1978 1 641 has sig1 => KDBX_SIG1, coerce => \&to_number;
  605 50       1144  
  1978         33287  
136 605 50 66 412 1 2325 has sig2 => KDBX_SIG2_2, coerce => \&to_number;
  1978 50       3103  
  412         990  
137 1978 50 100 1870 1 7380 has version => KDBX_VERSION_3_1, coerce => \&to_number;
  412 50       776  
  1870         37383  
138 412 50 100 106 1 1415 has headers => {};
  1870 50       2935  
  106         299  
139 1870 100 100 154 1 8780 has inner_headers => {};
  106 50       258  
  154         1004  
140 106 100 100 59 1 387 has meta => {};
  154 50       361  
  59         167  
141 154 50 100     639 has binaries => {};
  59         123  
142 59   50     308 has deleted_objects => {};
143 59 50   59 1 197 has raw => coerce => \&to_string;
144 59 50   59 1 124  
  59 50       172  
145 59 50 33 61 1 133 # HEADERS
  59 50       130  
  61         170  
146 59 100 33 73 1 107 has 'headers.comment' => '', coerce => \&to_string;
  61 50       139  
  73         293  
147 61 100 66 73 1 110 has 'headers.cipher_id' => CIPHER_UUID_CHACHA20, coerce => \&to_uuid;
  73 50       189  
  73         1911  
148 73 100 100 73 1 130 has 'headers.compression_flags' => COMPRESSION_GZIP, coerce => \&to_compression_constant;
  73 50       211  
  73         683  
149 73 100 100     148 has 'headers.master_seed' => sub { random_bytes(32) }, coerce => \&to_string;
  73         182  
150 73   100     130 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 1320 KDF_PARAM_AES_ROUNDS() => $_[0]->headers->{+HEADER_TRANSFORM_ROUNDS} // KDF_DEFAULT_AES_ROUNDS,
156 265 100       561 KDF_PARAM_AES_SEED() => $_[0]->headers->{+HEADER_TRANSFORM_SEED} // random_bytes(32),
157 265   100     545 };
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 737 # has 'headers.public_custom_data' => {};
164 59 50   59 1 267  
  59 50       171  
165 59 50 33 59 1 133 # META
  59 50       247  
  59         215  
166 59 50 33 59 1 110 has 'meta.generator' => '', coerce => \&to_string;
  59 50       119  
  59         275  
167 59 50 33 59 1 109 has 'meta.header_hash' => '', coerce => \&to_string;
  59 50       138  
  59         5152  
168 59 50 50 59 1 112 has 'meta.database_name' => '', coerce => \&to_string;
  59 50       159  
  59         176  
169 59 50 33 59 1 131 has 'meta.database_name_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       127  
  59         3488  
170 59 50 50 59 1 115 has 'meta.database_description' => '', coerce => \&to_string;
  59 50       141  
  59         167  
171 59 50 33 59 1 139 has 'meta.database_description_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       129  
  59         3389  
172 59 50 50 59 1 129 has 'meta.default_username' => '', coerce => \&to_string;
  59 50       141  
  59         173  
173 59 50 33 59 1 144 has 'meta.default_username_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       143  
  59         170  
174 59 50 33 59 1 133 has 'meta.maintenance_history_days' => HISTORY_DEFAULT_MAX_AGE, coerce => \&to_number;
  59 50       132  
  59         3343  
175 59 50 50 59 1 157 has 'meta.color' => '', coerce => \&to_string;
  59 50       147  
  59         167  
176 59 50 33     131 has 'meta.master_key_changed' => sub { gmtime }, coerce => \&to_time;
  59         123  
177 59 50 33 220 1 106 has 'meta.master_key_change_rec' => -1, coerce => \&to_number;
  220         591  
178 220 50   66 1 541 has 'meta.master_key_change_force' => -1, coerce => \&to_number;
  66 50       1203  
179 220 100 100 66 1 405 # has 'meta.memory_protection' => {};
  66 50       170  
  66         488  
180 66 100 66 59 1 121 has 'meta.custom_icons' => [];
  66 50       156  
  59         209  
181 66 50 66 59 1 165 has 'meta.recycle_bin_enabled' => true, coerce => \&to_bool;
  59 50       131  
  59         3450  
182 59 50 50 59 1 105 has 'meta.recycle_bin_uuid' => UUID_NULL, coerce => \&to_uuid;
  59 50       189  
  59         176  
183 59 50 33 59 1 144 has 'meta.recycle_bin_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       134  
  59         3414  
184 59 50 50 59 1 133 has 'meta.entry_templates_group' => UUID_NULL, coerce => \&to_uuid;
  59 50       130  
  59         190  
185 59 50 33 61 1 140 has 'meta.entry_templates_group_changed' => sub { gmtime }, coerce => \&to_time;
  59 50       135  
  61         180  
186 59 50 33 61 1 139 has 'meta.last_selected_group' => UUID_NULL, coerce => \&to_uuid;
  61 50       132  
  61         166  
187 61 50 66 59 1 121 has 'meta.last_top_visible_group' => UUID_NULL, coerce => \&to_uuid;
  61 50       124  
  59         156  
188 61 50 66     115 has 'meta.history_max_items' => HISTORY_DEFAULT_MAX_ITEMS, coerce => \&to_number;
  59         133  
189 59   50     103 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 3389 # has 'meta.binaries' => {};
192 59 50   59 1 150 # has 'meta.custom_data' => {};
  59 50       494  
193 59 50 33 59 1 147  
  59 50       128  
  59         405  
194 59 50 33 59 1 119 has 'memory_protection.protect_title' => false, coerce => \&to_bool;
  59 50       152  
  59         408  
195 59 50 33 59 1 119 has 'memory_protection.protect_username' => false, coerce => \&to_bool;
  59 50       152  
  59         412  
196 59 50 33     106 has 'memory_protection.protect_password' => true, coerce => \&to_bool;
  59         130  
197 59   33     122 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   81 my $self = shift;
210 59         181 $self->$_ for list_attributes(ref $self), @ATTRS;
211             }
212              
213              
214             sub memory_protection {
215 384     384 1 467 my $self = shift;
216 384 50 66     799 $self->{meta}{memory_protection} = shift if @_ == 1 && is_plain_hashref($_[0]);
217 384 100 100     1682 return $self->{meta}{memory_protection} //= {} if !@_;
218              
219 89         110 my $string_key = shift;
220 89         167 my $key = 'protect_' . lc($string_key);
221              
222 89 50       126 $self->meta->{memory_protection}{$key} = shift if @_;
223 89         147 $self->meta->{memory_protection}{$key};
224             }
225              
226              
227             sub minimum_version {
228 119     119 1 221 my $self = shift;
229              
230             return KDBX_VERSION_4_1 if any {
231             nonempty $_->{last_modification_time}
232 119 50   3   395 } values %{$self->custom_data};
  3         10  
  119         263  
233              
234             return KDBX_VERSION_4_1 if any {
235             nonempty $_->{name} || nonempty $_->{last_modification_time}
236 119 100 100 11   451 } @{$self->custom_icons};
  11         43  
  119         248  
237              
238             return KDBX_VERSION_4_1 if $self->groups->next(sub {
239             nonempty $_->previous_parent_group ||
240             nonempty $_->tags ||
241 156   100 156   331 (any { nonempty $_->{last_modification_time} } values %{$_->custom_data})
242 117 100       411 });
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   170 (any { nonempty $_->{last_modification_time} } values %{$_->custom_data})
248 114 100       1005 });
249              
250 111 100       1217 return KDBX_VERSION_4_0 if $self->kdf->uuid ne KDF_UUID_AES;
251              
252 101 100       396 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   324 nonempty $_->custom_data
256 97 100       233 });
257              
258 86         860 return KDBX_VERSION_3_1;
259             }
260              
261             ##############################################################################
262              
263              
264             sub root {
265 664     664 1 63129 my $self = shift;
266 664 100       1171 if (@_) {
267 21         177 $self->{root} = $self->_wrap_group(@_);
268 21         59 $self->{root}->kdbx($self);
269             }
270 664   66     1452 $self->{root} //= $self->_implicit_root;
271 664         1470 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   9 my $self = shift;
285 3         5 my $root = $self->root;
286 3         11 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     11 !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   91 my $self = shift;
303 62         4287 require File::KDBX::Group;
304 62 100       224 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   297 my $self = shift;
321 213         227 my $object = shift;
322 213         307 my @lineage = @_;
323              
324 213 100       472 push @lineage, $self->root if !@lineage;
325 213 50       417 my $base = $lineage[-1] or return [];
326              
327 213         385 my $uuid = $object->uuid;
328 213 100   170   562 return \@lineage if any { $_->uuid eq $uuid } @{$base->groups}, @{$base->entries};
  170         290  
  213         402  
  213         380  
329              
330 212         731 for my $subgroup (@{$base->groups}) {
  212         375  
331 90         210 my $result = $self->_trace_lineage($object, @lineage, $subgroup);
332 90 50       308 return $result if $result;
333             }
334             }
335              
336              
337             sub recycle_bin {
338 6     6 1 8 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         6 my $group;
344 6         12 my $uuid = $self->recycle_bin_uuid;
345 6 100       15 $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         15 $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 52 my $self = shift;
399 15 50       39 my $group = @_ % 2 == 1 ? shift : undef;
400 15         38 my %args = @_;
401              
402             # find the right group to add the group to
403 15   33     49 my $parent = delete $args{group} // $self->root;
404 15 50       40 $parent = $self->groups->grep({uuid => $parent})->next if !ref $parent;
405 15 50       30 $parent or throw 'Invalid group';
406              
407 15 50       58 return $parent->add_group(defined $group ? $group : (), %args, kdbx => $self);
408             }
409              
410             sub _wrap_group {
411 685     685   839 my $self = shift;
412 685         754 my $group = shift;
413 685         2746 require File::KDBX::Group;
414 685         1948 return File::KDBX::Group->wrap($group, $self);
415             }
416              
417              
418             sub groups {
419 133     133 1 195 my $self = shift;
420 133 50       372 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
421 133   33     453 my $base = delete $args{base} // $self->root;
422              
423 133         379 return $base->all_groups(%args);
424             }
425              
426             ##############################################################################
427              
428              
429             sub add_entry {
430 20     20 1 968 my $self = shift;
431 20 100       66 my $entry = @_ % 2 == 1 ? shift : undef;
432 20         91 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       59 $parent = $self->groups->grep({uuid => $parent})->next if !ref $parent;
437 20 50       48 $parent or throw 'Invalid group';
438              
439 20 100       96 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 1374 my $self = shift;
452 204 50       608 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
453 204   33     639 my $base = delete $args{base} // $self->root;
454              
455 204         631 return $base->all_entries(%args);
456             }
457              
458             ##############################################################################
459              
460              
461             sub objects {
462 106     106 1 158 my $self = shift;
463 106 50       270 my %args = @_ % 2 == 0 ? @_ : (base => shift, @_);
464 106   33     300 my $base = delete $args{base} // $self->root;
465              
466 106         315 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 7 my $self = shift;
506 5   100     12 my $uuid = shift // return;
507 4 50   4   14 my $icon = first { $_->{uuid} eq $uuid } @{$self->custom_icons} or return;
  4         10  
  4         8  
508 4         18 return $icon->{data};
509             }
510              
511              
512             sub add_custom_icon {
513 9     9 1 15 my $self = shift;
514 9 50       45 my %args = @_ % 2 == 1 ? (data => shift, @_) : @_;
515              
516 9 50       23 defined $args{data} or throw 'Must provide image data';
517              
518 9   33     58 my $uuid = $args{uuid} // generate_uuid;
519 9         20 push @{$self->custom_icons}, {
520             @_,
521             uuid => $uuid,
522             data => $args{data},
523 9         21 };
524 9         21 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         6 my @deleted;
532 5 100       9 @{$self->custom_icons} = grep { $_->{uuid} eq $uuid ? do { push @deleted, $_; 0 } : 1 }
  12         25  
  5         8  
  5         10  
533 5         7 @{$self->custom_icons};
  5         11  
534 5 50       19 $self->add_deleted_object($uuid) if @deleted;
535 5         17 return @deleted;
536             }
537              
538             ##############################################################################
539              
540              
541             sub custom_data {
542 120     120 1 157 my $self = shift;
543 120 50 33     307 $self->{meta}{custom_data} = shift if @_ == 1 && is_plain_hashref($_[0]);
544 120 100 100     756 return $self->{meta}{custom_data} //= {} if !@_;
545              
546 1 0       6 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       5 my $key = $args{key} or throw 'Must provide a custom_data key to access';
559              
560 1 50       4 return $self->{meta}{custom_data}{$key} = $args{value} if is_plain_hashref($args{value});
561              
562 1         5 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 1447 my $self = shift;
578 172 50 33     371 $self->{headers}{+HEADER_PUBLIC_CUSTOM_DATA} = shift if @_ == 1 && is_plain_hashref($_[0]);
579 172 50 100     795 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 13 my $self = shift;
607 11         12 my $uuid = shift;
608              
609             # ignore null and meta stream UUIDs
610 11 50 33     48 return if $uuid eq UUID_NULL || $uuid eq '0' x 16;
611              
612 11         28 $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 79 my $self = shift;
621 48         64 my $uuid = shift;
622 48         104 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 5502 my $self = shift;
636 18   50     56 my $wanted = shift // return;
637 18         28 my $search_in = shift;
638 18         23 my $text = shift;
639              
640 18 100       37 if (!defined $text) {
641 17         96 $wanted =~ s/^\{REF:([^\}]+)\}$/$1/i;
642 17         93 ($wanted, $search_in, $text) = $wanted =~ /^([TUPANI])\@([TUPANIO]):(.*)$/i;
643             }
644 18 50 66     124 $wanted && $search_in && nonempty($text) or return;
      66        
645              
646 17         113 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       33 $search_in = $fields{$search_in} or return;
657              
658 17 100       67 my $query = $search_in eq 'uuid' ? query($search_in => uuid($text))
659             : simple_expression_query($text, '=~', $search_in);
660              
661 17         65 my $entry = $self->entries->grep($query)->next;
662 17 100       258 $entry or return;
663              
664 15         61 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   72 my $self = shift;
747 50 100       143 $SAFE{$self} = shift if @_;
748 50         238 $SAFE{$self};
749             }
750              
751 90     90   518 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 11147 my $self = shift;
772 19 100       52 my $safe = $self->_safe or return $self;
773              
774 4         27 $safe->unlock;
775 4         21 $self->_remove_safe;
776              
777 4         19 return $self;
778             }
779              
780              
781             sub unlock_scoped {
782 15 50   15 1 42 throw 'Programmer error: Cannot call unlock_scoped in void context' if !defined wantarray;
783 15         20 my $self = shift;
784 15 50       37 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 7 my $self = shift;
794 4         7 my $string = shift;
795 4 50       10 my $safe = $self->_safe or return;
796 4         14 return $safe->peek($string);
797             }
798              
799              
800 15     15 1 36 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 5 my $self = shift;
827 1         2 my @removed;
828             $self->groups(algorithm => 'dfs')
829             ->where(-true => 'is_empty')
830 1     3   3 ->each(sub { push @removed, $_->remove });
  3         8  
831 1         33 return @removed;
832             }
833              
834              
835             sub remove_unused_icons {
836 2     2 1 3 my $self = shift;
837 2         3 my %icons = map { $_->{uuid} => 0 } @{$self->custom_icons};
  3         17  
  2         4  
838              
839 2   100 6   6 $self->objects->each(sub { ++$icons{$_->custom_icon_uuid // ''} });
  6         13  
840              
841 2         19 my @removed;
842 2         6 push @removed, $self->remove_custom_icon($_) for grep { $icons{$_} == 0 } keys %icons;
  5         13  
843 2         11 return @removed;
844             }
845              
846              
847             sub remove_duplicate_icons {
848 1     1 1 6 my $self = shift;
849              
850 1         2 my %seen;
851             my %dup;
852 1         2 for my $icon (@{$self->custom_icons}) {
  1         2  
853 3         16 my $digest = digest_data('SHA256', $icon->{data});
854 3 100       7 if (my $other = $seen{$digest}) {
855 1         4 $dup{$icon->{uuid}} = $other->{uuid};
856             }
857             else {
858 2         5 $seen{$digest} = $icon;
859             }
860             }
861              
862 1         2 my @removed;
863 1         5 while (my ($old_uuid, $new_uuid) = each %dup) {
864             $self->objects
865             ->where(custom_icon_uuid => $old_uuid)
866 1     1   2 ->each(sub { $_->custom_icon_uuid($new_uuid) });
  1         3  
867 1         16 push @removed, $self->remove_custom_icon($old_uuid);
868             }
869 1         6 return @removed;
870             }
871              
872              
873             sub prune_history {
874 2     2 1 20 my $self = shift;
875 2         6 my %args = @_;
876              
877 2   33     9 my $max_items = $args{max_items} // $self->history_max_items // HISTORY_DEFAULT_MAX_ITEMS;
      50        
878 2   33     6 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         3 my @removed;
882             $self->entries->each(sub {
883 2     2   8 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         28 return @removed;
890             }
891              
892              
893             sub randomize_seeds {
894 14     14 1 23 my $self = shift;
895 14         43 $self->encryption_iv(random_bytes(16));
896 14         37 $self->inner_random_stream_key(random_bytes(64));
897 14         41 $self->master_seed(random_bytes(32));
898 14         33 $self->stream_start_bytes(random_bytes(32));
899 14         31 $self->transform_seed(random_bytes(32));
900             }
901              
902             ##############################################################################
903              
904              
905             sub key {
906 36     36 1 61 my $self = shift;
907 36 50       188 $KEYS{$self} = File::KDBX::Key->new(@_) if @_;
908 36         98 $KEYS{$self};
909             }
910              
911              
912             sub composite_key {
913 45     45 1 65 my $self = shift;
914 45         2337 require File::KDBX::Key::Composite;
915 45         280 return File::KDBX::Key::Composite->new(@_);
916             }
917              
918              
919             sub kdf {
920 173     173 1 2762 my $self = shift;
921 173 50       440 my %args = @_ % 2 == 1 ? (params => shift, @_) : @_;
922              
923 173         266 my $params = $args{params};
924 173   50     466 my $compat = $args{compatible} // 1;
925              
926 173   33     630 $params //= $self->kdf_parameters;
927 173 50       217 $params = {%{$params || {}}};
  173         782  
928              
929 173 50 33     578 if (empty $params || !defined $params->{+KDF_PARAM_UUID}) {
930 0         0 $params->{+KDF_PARAM_UUID} = KDF_UUID_AES;
931             }
932 173 100       440 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       335 if ($self->version >= KDBX_VERSION_4_0) {
938 19         26 $params->{+KDF_PARAM_UUID} = KDF_UUID_AES_CHALLENGE_RESPONSE;
939             }
940 155   33     306 $params->{+KDF_PARAM_AES_SEED} //= $self->transform_seed;
941 155   33     287 $params->{+KDF_PARAM_AES_ROUNDS} //= $self->transform_rounds;
942             }
943              
944 173         4696 require File::KDBX::KDF;
945 173         1048 return File::KDBX::KDF->new(%$params);
946             }
947              
948             sub transform_seed {
949 81     81 1 444 my $self = shift;
950             $self->headers->{+HEADER_TRANSFORM_SEED} =
951 81 100       181 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_SEED} = shift if @_;
952             $self->headers->{+HEADER_TRANSFORM_SEED} =
953 81   33     165 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_SEED} //= random_bytes(32);
954             }
955              
956             sub transform_rounds {
957 67     67 1 114 my $self = shift;
958             $self->headers->{+HEADER_TRANSFORM_ROUNDS} =
959 67 50       138 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_ROUNDS} = shift if @_;
960             $self->headers->{+HEADER_TRANSFORM_ROUNDS} =
961 67   50     126 $self->headers->{+HEADER_KDF_PARAMETERS}{+KDF_PARAM_AES_ROUNDS} //= 100_000;
962             }
963              
964              
965             sub cipher {
966 39     39 1 72 my $self = shift;
967 39         115 my %args = @_;
968              
969 39   33     215 $args{uuid} //= $self->headers->{+HEADER_CIPHER_ID};
970 39   33     171 $args{iv} //= $self->headers->{+HEADER_ENCRYPTION_IV};
971              
972 39         136 require File::KDBX::Cipher;
973 39         186 return File::KDBX::Cipher->new(%args);
974             }
975              
976              
977             sub random_stream {
978 18     18 1 40 my $self = shift;
979 18         35 my %args = @_;
980              
981 18   33     126 $args{stream_id} //= delete $args{id} // $self->inner_random_stream_id;
      33        
982 18   33     88 $args{key} //= $self->inner_random_stream_key;
983              
984 18         87 require File::KDBX::Cipher;
985 18         138 File::KDBX::Cipher->new(%args);
986             }
987              
988             sub inner_random_stream_id {
989 85     85 1 783 my $self = shift;
990             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_ID}
991 85 100       202 = $self->headers->{+HEADER_INNER_RANDOM_STREAM_ID} = shift if @_;
992             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_ID}
993 85   66     158 //= $self->headers->{+HEADER_INNER_RANDOM_STREAM_ID} //= do {
      66        
994 59         151 my $version = $self->minimum_version;
995 59 50       373 $version < KDBX_VERSION_4_0 ? STREAM_ID_SALSA20 : STREAM_ID_CHACHA20;
996             };
997             }
998              
999             sub inner_random_stream_key {
1000 107     107 1 246 my $self = shift;
1001 107 100       233 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         47 erase \$self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY};
1005 22         93 erase \$self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY};
1006             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY}
1007 22         52 = $self->headers->{+HEADER_INNER_RANDOM_STREAM_KEY} = shift;
1008             }
1009             $self->inner_headers->{+INNER_HEADER_INNER_RANDOM_STREAM_KEY}
1010 107   66     193 //= $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         84 my $object = shift;
1018 68         91 my $type = shift;
1019              
1020 68         306 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       180 my $handler = $handlers{$type} or return;
1029 68         149 $self->$handler($object, @_);
1030             }
1031              
1032             sub _handle_object_added {
1033 48     48   68 my $self = shift;
1034 48         81 my $object = shift;
1035 48         101 $self->remove_deleted_object($object->uuid);
1036             }
1037              
1038             sub _handle_object_removed {
1039 6     6   8 my $self = shift;
1040 6         7 my $object = shift;
1041 6   50     14 my $old_uuid = $object->{uuid} // return;
1042              
1043 6         11 my $meta = $self->meta;
1044 6 50 50     15 $self->recycle_bin_uuid(UUID_NULL) if $old_uuid eq ($meta->{recycle_bin_uuid} // '');
1045 6 50 50     15 $self->entry_templates_group(UUID_NULL) if $old_uuid eq ($meta->{entry_templates_group} // '');
1046 6 50 50     23 $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         13 $self->add_deleted_object($old_uuid);
1050             }
1051              
1052             sub _handle_entry_uuid_changed {
1053 13     13   23 my $self = shift;
1054 13         17 my $object = shift;
1055 13         17 my $new_uuid = shift;
1056 13   50     57 my $old_uuid = shift // return;
1057              
1058 13         31 my $old_pretty = format_uuid($old_uuid);
1059 13         27 my $new_pretty = format_uuid($new_uuid);
1060 13         161 my $fieldref_match = qr/\{REF:([TUPANI])\@I:\Q$old_pretty\E\}/is;
1061              
1062             $self->entries->each(sub {
1063 26 50 50 26   77 $_->previous_parent_group($new_uuid) if $old_uuid eq ($_->{previous_parent_group} // '');
1064              
1065 26         28 for my $string (values %{$_->strings}) {
  26         42  
1066 130 100 66     441 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         33 });
1072             }
1073              
1074             sub _handle_group_uuid_changed {
1075 1     1   3 my $self = shift;
1076 1         3 my $object = shift;
1077 1         2 my $new_uuid = shift;
1078 1   50     4 my $old_uuid = shift // return;
1079              
1080 1         4 my $meta = $self->meta;
1081 1 50 50     7 $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     5 $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   10 $_->last_top_visible_entry($new_uuid) if $old_uuid eq ($_->{last_top_visible_entry} // '');
1088 2 50 50     11 $_->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   14 $_->previous_parent_group($new_uuid) if $old_uuid eq ($_->{previous_parent_group} // '');
1092 1         8 });
1093             }
1094              
1095             #########################################################################################
1096              
1097              
1098             #########################################################################################
1099              
1100 0     0 0   sub TO_JSON { +{%{$_[0]}} }
  0            
1101              
1102             1;
1103              
1104             __END__