File Coverage

lib/Data/Identifier/Generate.pm
Criterion Covered Total %
statement 108 242 44.6
branch 48 152 31.5
condition 41 149 27.5
subroutine 15 20 75.0
pod 8 8 100.0
total 220 571 38.5


line stmt bran cond sub pod time code
1             # Copyright (c) 2023-2025 Philipp Schafft
2              
3             # licensed under Artistic License 2.0 (see LICENSE file)
4              
5             # ABSTRACT: format independent identifier object
6              
7              
8             package Data::Identifier::Generate;
9              
10 3     3   425163 use v5.20;
  3         10  
11 3     3   16 use strict;
  3         6  
  3         75  
12 3     3   11 use warnings;
  3         5  
  3         232  
13              
14 3     3   19 use Carp;
  3         7  
  3         303  
15 3     3   18 use Encode qw(encode);
  3         5  
  3         308  
16 3     3   1765 use Digest;
  3         2272  
  3         117  
17              
18 3     3   767 use Data::Identifier;
  3         9  
  3         25  
19              
20             use constant {
21 3         11819 NS_GTE => '90355e77-6942-4d82-a0b6-3a6de65bf948',
22              
23             WK_UNSIGNED_INTEGER_GENERATOR => '53863a15-68d4-448d-bd69-a9b19289a191',
24             WK_SIGNED_INTEGER_GENERATOR => 'e8aa9e01-8d37-4b4b-8899-42ca0a2a906f',
25             WK_UNICODE_CHARACTER_GENERATOR => 'd74f8c35-bcb8-465c-9a77-01010e8ed25c',
26             WK_RGB_COLOUR_GENERATOR => '55febcc4-6655-4397-ae3d-2353b5856b34',
27             WK_DATE_GENERATOR => '97b7f241-e1c5-4f02-ae3c-8e31e501e1dc',
28             #WK_LANGUAGE_GENERATOR => '',
29             WK_MULTIPLICITY_GENERATOR => '19659233-0a22-412c-bdf1-8ee9f8fc4086',
30             WK_MINIMUM_MULTIPLICITY_GENERATOR => '5ec197c3-1406-467c-96c7-4b1a6ec2c5c9',
31 3     3   21 };
  3         6  
32              
33              
34             our $VERSION = v0.28;
35              
36             my %_multiplicity_prefix = (
37             total => '4.1',
38             minimum => '4.3',
39             );
40              
41             my %_multiplicity_names = (
42             0 => 'noone',
43             1 => 'solo',
44             2 => 'duo',
45             3 => 'trio',
46             );
47              
48             my %_multiplicity_generators = (
49             total => WK_MULTIPLICITY_GENERATOR,
50             minimum => WK_MINIMUM_MULTIPLICITY_GENERATOR,
51             );
52              
53             my %_gte_simple_profiles = (
54             'ab332382-a6f8-4a24-914d-5f823dd866c1' => {
55             namespace => 'a13377a3-88d4-484e-90a8-245afb22a793',
56             order => 'MFHCSmfhcs',
57             case_folding => undef,
58             strip_slash => undef,
59             strip_spaces => undef,
60             },
61             'e537db94-85b9-4125-972a-cc2ea1fdf51d' => {
62             namespace => '52f20647-1dc7-4234-81ad-0639ab6cef60',
63             order => 'FAfa',
64             case_folding => undef,
65             strip_slash => undef,
66             strip_spaces => undef,
67             },
68             '60764502-18cb-41c9-8531-e4c8b43140b9' => {
69             namespace => '1e2edf8d-d459-47cb-9d6e-0690f404fadf',
70             order => 'MFHCSmfhcs',
71             case_folding => undef,
72             strip_slash => undef,
73             strip_spaces => undef,
74             },
75             '5aad9d75-7020-41a4-8ec6-2bf09566f985' => {
76             namespace => 'fc9741b4-2ac7-412b-9d36-2b675fc0482b',
77             order => 'NDTBndtb',
78             case_folding => undef,
79             strip_slash => undef,
80             strip_spaces => undef,
81             },
82             );
83              
84              
85             #@returns Data::Identifier
86             sub integer {
87 6     6 1 4928 my ($pkg, $request, %opts) = @_;
88 6         14 $opts{request} = $request;
89 6         10 $opts{style} = 'integer-based';
90 6         38 $opts{namespace} = Data::Identifier->NS_INT();
91 6   33     43 $opts{displayname}//= $request;
92 6 100       17 $opts{generator} = $request >= 0 ? WK_UNSIGNED_INTEGER_GENERATOR : WK_SIGNED_INTEGER_GENERATOR;
93              
94 6         28 return $pkg->generic(%opts);
95             }
96              
97              
98             sub unicode_character {
99 3     3 1 3040 my ($pkg, $type, $request, %opts) = @_;
100 3         5 my $unicode_cp;
101             my $unicode_cp_str;
102              
103 3 50       17 if ($type eq 'unicode') {
    50          
    50          
104 0 0       0 if ($request =~ /^[Uu]\+([0-9a-fA-F]+)$/) {
105 0         0 $unicode_cp = hex($1);
106             } else {
107 0         0 $unicode_cp = int($request);
108             }
109             } elsif ($type eq 'ascii') {
110 0         0 $unicode_cp = int($request);
111 0 0 0     0 croak 'US-ASCII character out of range: '.$unicode_cp if $unicode_cp < 0 || $unicode_cp > 0x7F;
112             } elsif ($type eq 'raw') {
113 3 50       9 croak 'Raw value is not exactly one character long' unless length($request) == 1;
114 3         4 $unicode_cp = ord($request);
115             }
116              
117 3 50 33     14 croak 'Unicode character out of range: '.$unicode_cp if $unicode_cp < 0 || $unicode_cp > 0x10FFFF;
118              
119 3         11 $unicode_cp_str = sprintf('U+%04X', $unicode_cp);
120              
121 3 50 33     21 if ($unicode_cp == 0xFFFC || $unicode_cp == 0xFFFD || $unicode_cp == 0xFEFF || $unicode_cp == 0xFFFE) {
      33        
      33        
122 0 0       0 croak 'Rejected use of special character: '.$unicode_cp_str unless $opts{allow_special};
123             }
124              
125 3   33     21 $opts{displayname} //= $unicode_cp_str;
126              
127 3         12 return Data::Identifier->new(unicodecp => $unicode_cp_str, displayname => $opts{displayname}, generator => WK_UNICODE_CHARACTER_GENERATOR, request => $unicode_cp_str);
128             }
129              
130              
131             #@returns Data::Identifier
132             sub colour {
133 5     5 1 3958 my ($pkg, $colour, %opts) = @_;
134 5         7 $opts{request} = $colour;
135 5         8 $opts{style} = 'colour';
136 5         7 $opts{namespace} = '88d3944f-a13b-4e35-89eb-e3c1fbe53e76';
137 5         7 $opts{generator} = WK_RGB_COLOUR_GENERATOR;
138 5         12 return $pkg->generic(%opts);
139             }
140              
141              
142             #@returns Data::Identifier
143             sub date {
144 9     9 1 7456 my ($pkg, $request, %opts) = @_;
145 9         14 my ($year, $month, $day);
146 9         0 my $precision;
147              
148 9 50       18 if (ref($request)) {
149 0 0       0 if (eval {$request->can('epoch')}) {
  0         0  
150 0         0 $request = $request->epoch;
151             } else {
152 0         0 return $pkg->date(scalar($request->()), %opts);
153             }
154             }
155              
156 9         47 ($year, $month, $day) = $request =~ /^([12][0-9]{3})(?:-([01][0-9])(?:-([0-3][0-9]))?)?Z$/;
157              
158 9 100 100     28 unless (length($year // '') == 4) {
159 3 50 33     29 if ($request eq 'now' || $request eq 'today') {
    50          
160 0         0 $request = time();
161             } elsif ($request =~ /^(?:0|-?[1-9][0-9]*)$/) {
162 3         5 $request = int($request);
163 3 50       5 if ($request > 32503680000) {
164 0         0 croak 'Unlikely far date given. Likely miliseconds are passed as seconds?';
165             }
166             } else {
167 0         0 croak 'Invalid format';
168             }
169              
170 3         13 (undef,undef,undef,$day,$month,$year) = gmtime($request);
171 3         5 $year += 1900;
172 3         4 $month += 1;
173             }
174              
175 9         12 foreach my $entry ($year, $month, $day) {
176 27   100     42 $entry = int($entry // 0);
177             }
178              
179 9 50 33     32 croak 'Invalid year' if $year && ($year < 1583 || $year > 9999);
      33        
180 9 50 33     590 croak 'Invalid month' if $month && ($month < 1 || $month > 12);
      66        
181 9 50 33     21 croak 'Invalid day' if $day && ($day < 1 || $day > 31);
      66        
182              
183 9 50       10 $month = 0 unless $year;
184 9 100       22 $day = 0 unless $month;
185              
186 9 100 66     40 $precision = $opts{precision} // ($day ? 'day' : undef) // ($month ? 'month' : undef) // 'year';
    100 100        
      100        
187 9 100 66     30 if ($precision eq 'day' && $day) {
    100 66        
    50 33        
188 7         25 $request = sprintf('%04u-%02u-%02uZ', $year, $month, $day);
189             } elsif ($precision eq 'month' && $month) {
190 1         4 $request = sprintf('%04u-%02uZ', $year, $month);
191             } elsif ($precision eq 'year' && $year) {
192 1         4 $request = sprintf('%04uZ', $year);
193             } else {
194 0         0 croak 'Bad precision: '.$precision;
195             }
196              
197 9         12 $opts{request} = $request;
198 9   33     48 $opts{input} //= $request; # force raw value!
199 9         12 $opts{style} = undef;
200 9         59 $opts{namespace} = Data::Identifier->NS_DATE();
201 9   33     26 $opts{displayname}//= $request;
202 9         9 $opts{generator} = WK_DATE_GENERATOR;
203              
204 9         26 return $pkg->generic(%opts);
205             }
206              
207              
208             sub language {
209 0     0 1 0 my ($pkg, $req, %opts) = @_;
210 0         0 my $name;
211              
212 0         0 require I18N::LangTags::List;
213              
214 0         0 $opts{request} = $req;
215 0         0 $opts{style} = 'id-based';
216 0         0 $opts{namespace} = '47dd950c-9089-4956-87c1-54c122533219';
217             #$opts{generator} = WK_LANGUAGE_GENERATOR;
218              
219 0 0       0 croak 'Bad language: '.$req unless I18N::LangTags::List::is_decent($req);
220              
221 0         0 $name = I18N::LangTags::List::name($req);
222              
223 0 0 0     0 unless (defined($name) && length($name)) {
224 0         0 croak 'Bad language: '.$req;
225             }
226              
227 0   0     0 $opts{displayname} //= $name;
228 0         0 return $pkg->generic(%opts);
229             }
230              
231              
232             sub multiplicity {
233 0     0 1 0 my ($pkg, $subtype, $request, %opts) = @_;
234 0   0     0 my $prefix = $_multiplicity_prefix{$subtype} // croak 'Invalid subtype: '.$subtype;
235 0         0 my $identifier;
236             my $oid;
237              
238 0 0 0     0 croak 'Invalid value: '.$request unless $request eq '0' || $request =~ /^[1-9][0-9]*$/;
239              
240 0         0 $oid = '1.3.6.1.4.1.46942.16.2.'.$prefix.'.'.$request;
241              
242 0         0 $opts{request} = $request;
243 0         0 $opts{input} = $prefix.'.'.$request;
244 0         0 $opts{namespace} = NS_GTE;
245 0   0     0 $opts{displayname}//= $_multiplicity_names{$request};
246 0   0     0 $opts{displayname}//= $request;
247 0         0 $opts{generator} = $_multiplicity_generators{$subtype};
248 0         0 $identifier = $pkg->generic(%opts);
249              
250 0   0     0 $identifier->{id_cache} //= {};
251 0   0     0 $identifier->{id_cache}->{Data::Identifier->WK_OID} //= $oid;
252              
253 0 0       0 if (defined $_multiplicity_names{$request}) {
254 0         0 $identifier->register;
255             }
256              
257 0         0 return $identifier;
258             }
259              
260              
261             sub gte_simple {
262 0     0 1 0 my ($pkg, $profile, $request, %opts) = @_;
263 0         0 my %order;
264             my $normal;
265              
266 0 0       0 croak 'Called in list context' if wantarray;
267              
268 0 0       0 $profile = $profile->ise if eval {$profile->can('ise')};
  0         0  
269 0   0     0 $profile = $_gte_simple_profiles{$profile} // $profile;
270              
271             {
272 0         0 my $i = 0;
  0         0  
273 0         0 %order = map {$_ => $i++} split(//, $profile->{order});
  0         0  
274             }
275              
276 0 0       0 if (defined(my $folding = $profile->{case_folding})) {
277 0 0       0 if ($folding eq 'none') {
    0          
    0          
278             # no-op
279             } elsif ($folding eq 'upper') {
280 0         0 $request = uc($request);
281             } elsif ($folding eq 'lower') {
282 0         0 $request = lc($request);
283             } else {
284 0         0 croak 'Unsupported/invalid folding rule: '.$folding;
285             }
286             }
287              
288 0 0       0 if ($profile->{strip_slash}) {
289 0         0 $request =~ s#/+##g;
290             }
291              
292 0 0       0 if ($profile->{strip_spaces}) {
293 0         0 $request =~ s#\s+##g;
294             }
295              
296             $normal = join('',
297 0         0 sort {$order{$a} <=> $order{$b}}
298 0 0       0 map {croak 'Invalid input element: '.$_ unless defined $order{$_}; $_}
  0         0  
  0         0  
299             split //, $request);
300              
301 0 0       0 if (defined(my $info = delete $opts{info})) {
302 0         0 $info->{count} = length($normal);
303 0         0 $info->{request} = $normal;
304             }
305              
306 0         0 $opts{input} = $normal;
307 0         0 $opts{request} = $normal;
308 0   0     0 $opts{namespace} //= $profile->{namespace};
309 0   0     0 $opts{displayname}//= $normal;
310 0         0 return $pkg->generic(%opts);
311             }
312              
313              
314             #@returns Data::Identifier
315             sub generic {
316 20     20 1 78 my ($pkg, %opts) = @_;
317              
318 20 50       46 if (defined(my $type)) {
319 0   0     0 $opts{namespace} //= $type->namespace;
320             }
321              
322 20 100 66     72 if (defined(my $style = $opts{style}) && defined(my $request = $opts{request})) {
323 11 100       46 if ($style eq 'integer-based') {
    50          
    50          
    50          
    50          
    50          
324 6 50       46 croak 'Invalid request' unless $request =~ /^(?:0|-?[1-9][0-9]*)$/;
325 6   66     26 $opts{input} //= $request;
326             } elsif ($style eq 'id-based') {
327 0         0 my ($input, $name);
328              
329 0 0       0 if (($input, $name) = $request =~ /^(#?[a-zA-Z0-9\-\.\+]+) (.+)$/) {
    0          
330             # noop
331             } elsif (($input) = $request =~ /^(#?[a-zA-Z0-9\-\.\+]+)$/) {
332 0         0 $name = undef;
333             } else {
334 0         0 croak 'Invalid format, expected: "id name", or "id", got: '.$request;
335             }
336              
337 0   0     0 $opts{input} //= lc($input);
338 0   0     0 $opts{displayname} //= $name;
339              
340             } elsif ($style eq 'name-based') {
341 0   0     0 $opts{input} //= encode('UTF-8', $request);
342 0   0     0 $opts{displayname} //= $request;
343             } elsif ($style eq 'tag-based') {
344 0 0       0 if (ref($request)) {
    0          
345 0         0 my $identifier = Data::Identifier->new(from => $request);
346 0   0     0 $opts{input} //= $identifier->uuid;
347 0   0     0 $opts{displayname} //= $identifier->{displayname}; # steal the raw value here.
348             } elsif ($request =~ Data::Identifier->RE_UUID) {
349 0   0     0 $opts{input} //= $request;
350             } else {
351 0         0 croak 'Invalid format for tag-based generator: '.$request;
352             }
353             } elsif ($style eq 'tagcombiner') {
354 0 0       0 if (ref($request) eq 'ARRAY') {
355 0         0 my %uuids;
356              
357 0         0 foreach my $entry (@{$request}) {
  0         0  
358 0 0       0 if (ref($entry)) {
    0          
359 0         0 $uuids{Data::Identifier->new(from => $entry)->uuid} = undef;
360             } elsif ($entry =~ Data::Identifier->RE_UUID) {
361 0         0 $uuids{$entry} = undef;
362             } else {
363 0         0 croak 'Invalid format for tag-based generator: '.$entry;
364             }
365             }
366              
367 0 0       0 croak 'Less than two tags being combined' unless scalar(keys %uuids) > 1;
368              
369 0   0     0 $opts{input} //= join(',', sort keys %uuids);
370             } else {
371 0         0 croak 'Invalid request for tagcombiner generator: '.$request;
372             }
373             } elsif ($style eq 'colour') {
374 5 50       7 if (defined $request) {
375 5         8 my $req = lc($request);
376              
377 5 50       39 $req = sprintf('#%s%s%s', $1 x 6, $2 x 6, $3 x 6) if $req =~ /^#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/;
378              
379 5 50       12 if ($req =~ /^#[a-f0-9]{36}$/) {
380 5   33     15 $opts{input} //= $req;
381             } else {
382 0         0 croak 'Bad format for colour';
383             }
384             }
385             } else {
386 0         0 croak 'Style not supported: '.$style;
387             }
388             }
389              
390 20 50       38 croak 'No valid style/request or input is provided' unless defined $opts{input};
391              
392             {
393 20 50       19 my $ns = ref($opts{namespace}) ? $opts{namespace}->uuid(no_defaults => 1) : $opts{namespace};
  20         34  
394 20         68 my $uuid = $pkg->_uuid_v5($ns, $opts{input});
395 20         90 my $tag = Data::Identifier->new(uuid => $uuid, displayname => $opts{displayname});
396 20   66     113 $tag->{$_} //= $opts{$_} foreach qw(generator request);
397 20 50 33     103 $tag->{generator} = Data::Identifier->new(from => $tag->{generator}) if defined($tag->{generator}) && !ref($tag->{generator});
398 20         107 return $tag;
399             }
400             }
401              
402             # ---- Private helpers ----
403              
404             sub _available_module {
405 0     0   0 my (@modules) = @_;
406 0         0 state $tried = {};
407              
408 0         0 foreach my $module (@modules) {
409 0 0       0 if (exists $tried->{$module}) {
410 0 0       0 next unless $tried->{$module};
411 0         0 return $module;
412             } else {
413 0         0 my $res = eval {
414 0         0 my $modname = $module =~ s#::#/#gr;
415 0         0 require $modname.'.pm';
416 0         0 1;
417             };
418 0         0 $tried->{$module} = $res;
419 0 0       0 return $module if $res;
420             }
421             }
422              
423 0         0 croak 'Found none of modules ['.join(', ', @modules).']';
424             }
425              
426             sub _finish {
427 23     23   56 my ($raw, $version) = @_;
428 23         91 substr($raw, 6, 1, chr((ord(substr($raw, 6, 1)) & 0x0F) | ($version << 4)));
429 23         41 substr($raw, 8, 1, chr((ord(substr($raw, 8, 1)) & 0x3F) | 0x80));
430 23         196 return join('-', unpack('H8H4H4H4H12', $raw));
431             }
432              
433             sub _random {
434 0     0   0 my ($pkg, %opts) = @_;
435 0   0     0 my $sources = $opts{sources} // (state $default_sources = [qw(Crypt::URandom UUID4::Tiny Math::Random::Secure UUID::URandom UUID::Tiny::Patch::UseMRS)]);
436 0         0 my $source = _available_module(@{$sources});
  0         0  
437 0         0 my $raw;
438              
439             # Secure:
440 0 0       0 if ($source eq 'Crypt::URandom') {
    0          
    0          
    0          
    0          
    0          
    0          
441 0         0 $raw = Crypt::URandom::urandom(16);
442             } elsif ($source eq 'UUID4::Tiny') {
443 0         0 return UUID4::Tiny::create_uuid_string();
444             } elsif ($source eq 'Math::Random::Secure') {
445 0         0 $raw = join('', map {chr Math::Random::Secure::irand(256)} 0..15);
  0         0  
446             } elsif ($source eq 'UUID::URandom') {
447 0         0 return UUID::URandom::create_uuid_string();
448             } elsif ($source eq 'UUID::Tiny::Patch::UseMRS') {
449 0         0 return UUID::Tiny::create_uuid_as_string(UUID::Tiny::UUID_RANDOM());
450              
451             # Insecure:
452             } elsif ($source eq 'UUID::Tiny') {
453 0         0 return UUID::Tiny::create_uuid_as_string(UUID::Tiny::UUID_RANDOM());
454             } elsif ($source eq 'Data::UUID') {
455 0         0 require Data::UUID;
456 0         0 return Data::UUID->create_str;
457             #} elsif ($source eq '') {
458             } else {
459 0         0 croak 'Invalid/unsupported source';
460             }
461              
462 0 0 0     0 if (defined($raw) && length($raw) == 16) {
463 0         0 return _finish($raw, 4);
464             }
465              
466 0         0 croak 'Bug!';
467             }
468              
469             sub _uuid_v5 {
470 23     23   44 my ($pkg, $ns, $data) = @_;
471 23         81 my $digest = Digest->new('SHA-1');
472              
473 23 50       8927 $ns = $ns->uuid(no_defaults => 1) if ref $ns;
474              
475 23         102 $ns = pack('H*', $ns =~ tr/-//dr);
476              
477 23 50       51 croak 'Invalid namespace given' unless length($ns) == 16;
478              
479 23         80 $digest->add($ns);
480 23         42 $digest->add($data);
481              
482 23         111 return _finish(substr($digest->digest, 0, 16), 5);
483             }
484              
485             1;
486              
487             __END__