File Coverage

blib/lib/URI/PackageURL/Type.pm
Criterion Covered Total %
statement 163 186 87.6
branch 88 108 81.4
condition 27 38 71.0
subroutine 26 44 59.0
pod 30 30 100.0
total 334 406 82.2


line stmt bran cond sub pod time code
1             package URI::PackageURL::Type;
2              
3 9     9   60 use feature ':5.10';
  9         17  
  9         1247  
4 9     9   76 use strict;
  9         21  
  9         277  
5 9     9   38 use utf8;
  9         17  
  9         47  
6 9     9   294 use warnings;
  9         20  
  9         720  
7              
8 9     9   51 use Carp ();
  9         16  
  9         240  
9 9     9   54 use File::Basename qw(dirname);
  9         26  
  9         836  
10 9     9   51 use File::Spec;
  9         17  
  9         358  
11 9     9   3863 use JSON::PP qw(decode_json);
  9         103392  
  9         766  
12 9     9   83 use List::Util qw(first);
  9         16  
  9         712  
13              
14 9     9   49 use constant DEBUG => $ENV{PURL_DEBUG};
  9         17  
  9         36066  
15              
16             our $VERSION = '2.25';
17              
18              
19             my %ALGO_LENGTH = ('md5' => 32, 'sha1' => 40, 'sha256' => 64, 'sha384' => 96, 'sha512' => 128);
20              
21             my %CACHE = ();
22              
23             sub new {
24              
25 1626     1626 1 3962 my ($class, $type) = @_;
26              
27 1626 50       4364 Carp::croak 'Missing PURL type' unless $type;
28              
29 1626         3765 $type = lc $type;
30              
31 1626   100     5752 my $self = {type => $type, definition => _load_definition($type) || {}};
32              
33 1626         6230 return bless $self, $class;
34              
35             }
36              
37 89     89 1 9173 sub definition_dir { File::Spec->catfile(dirname(__FILE__), 'types') }
38              
39             sub _file_content {
40              
41 89     89   258 my $path = shift;
42              
43 89 100       7073 return unless -e $path;
44              
45 82 50       4971 open my $fh, '<', $path or Carp::croak "Can't open file: $!";
46 82         217 my $content = do { local $/; <$fh> };
  82         476  
  82         4530  
47 82         1425 close $fh;
48              
49 82         536 return $content;
50              
51             }
52              
53             sub _load_definition {
54              
55 1626     1626   2877 my $purl_type = shift;
56              
57 1626 100       10283 return $CACHE{$purl_type} if defined $CACHE{$purl_type};
58              
59 89         343 my $content = _file_content(File::Spec->catfile(definition_dir, "$purl_type-definition.json"));
60 89 100       546 return unless $content;
61              
62 82         155 DEBUG and say STDERR "-- Loaded '$purl_type' definition schema";
63              
64 82         186 my $data = eval { decode_json($content) };
  82         525  
65 82 50       1317193 Carp::croak "Failed to decode '$purl_type' PURL type definition: $@" if $@;
66              
67 82         407 $CACHE{$purl_type} = $data;
68 82         844 return $data;
69              
70             }
71              
72 40501     40501 1 121805 sub definition { shift->{definition} }
73              
74             sub _property {
75              
76 19440     19440   33198 my ($self, $property, $sub_property) = @_;
77              
78 19440 50       30465 return unless $self->definition;
79              
80 19440 100       37064 return $self->definition->{$property} unless defined $sub_property;
81 11498         18776 return $self->definition->{$property}->{$sub_property};
82              
83             }
84              
85 0     0 1 0 sub schema_id { shift->_property('$id') }
86 0     0 1 0 sub type_name { shift->_property('type_name') }
87 0     0 1 0 sub description { shift->_property('description') }
88 1859     1859 1 4561 sub default_repository_url { shift->_property('repository', 'default_repository_url') }
89 0 0   0 1 0 sub examples { shift->_property('examples') || [] }
90 0     0 1 0 sub repository { shift->_property('repository') }
91 0     0 1 0 sub note { shift->_property('note') }
92 0     0 1 0 sub reference_urls { shift->_property('reference_urls') }
93              
94 0     0 1 0 sub namespace_definition { shift->component_definition('namespace') }
95 0     0 1 0 sub name_definition { shift->component_definition('name') }
96 0     0 1 0 sub version_definition { shift->component_definition('version') }
97 1582 100   1582 1 3011 sub qualifiers_definition { shift->component_definition('qualifiers') || [] }
98 0     0 1 0 sub subpath_definition { shift->component_definition('subpath') }
99              
100 6360     6360 1 12054 sub component_have_definition { defined shift->component_definition(shift) }
101 7942     7942 1 18306 sub component_definition { shift->_property(shift . '_definition') }
102 0     0 1 0 sub component_case_sensitive { shift->_property(shift . '_definition', 'case_sensitive') }
103 0     0 1 0 sub component_is_case_sensitive { shift->component_case_sensitive(shift) == 1 }
104 0     0 1 0 sub component_is_optional { shift->component_requirement(shift) eq 'optional' }
105 4438     4438 1 8120 sub component_is_prohibited { shift->component_requirement(shift) eq 'prohibited' }
106 377     377 1 770 sub component_is_required { shift->component_requirement(shift) eq 'required' }
107 0     0 1 0 sub component_native_name { shift->_property(shift . '_definition', 'native_name') }
108 0 0   0 1 0 sub component_normalization_rules { shift->_property(shift . '_definition', 'normalization_rules') || [] }
109 0     0 1 0 sub component_note { shift->_property(shift . '_definition', 'note') }
110 0     0 1 0 sub component_permitted_characters { shift->_property(shift . '_definition', 'permitted_characters') }
111 9639     9639 1 19801 sub component_requirement { shift->_property(shift . '_definition', 'requirement') }
112              
113             sub normalize {
114              
115 1626     1626 1 2738 my $self = shift;
116              
117 1626         13982 my %components = (
118             type => undef,
119             namespace => undef,
120             name => undef,
121             version => undef,
122             version => undef,
123             qualifiers => {},
124             subpath => undef,
125             @_
126             );
127              
128             # Common normalizations
129 1626         6307 $components{type} = lc $components{type};
130              
131 1626 100       4414 if (grep { $_ eq $components{type} } qw(alpm apk bitbucket composer deb github gitlab hex npm oci pypi)) {
  17886         32567  
132 150         396 $components{name} = lc $components{name};
133             }
134              
135 1626 100       8183 if (defined $components{namespace}) {
136              
137 1330 100       2699 if (grep { $_ eq $components{type} } qw(alpm apk bitbucket composer deb github gitlab golang hex rpm)) {
  13300         22289  
138 126         305 $components{namespace} = lc $components{namespace};
139             }
140              
141             # The namespace is the CPAN id of the author/publisher. It MUST be written uppercase and is required.
142              
143 1330 100       3510 if ($components{type} eq 'cpan') {
144 997         3059 $components{namespace} = uc $components{namespace};
145             }
146              
147             }
148              
149             # Force checksum into ARRAY
150 1626 100 100     5428 if (defined $components{qualifiers}->{checksum} and ref $components{qualifiers}->{checksum} ne 'ARRAY') {
151 2         9 $components{qualifiers}->{checksum} = [$components{qualifiers}->{checksum}];
152             }
153              
154             # PURL type specific normalization
155 1626         4227 TYPE: for ($components{type}) {
156              
157 1626 100       4223 if (/huggingface/) {
158              
159             # The version is the model revision Git commit hash. It is case insensitive and
160             # must be lowercased in the package URL.
161              
162 27         67 $components{version} = lc $components{version};
163 27         61 last TYPE;
164             }
165              
166 1599 100       3981 if (/mlflow/) {
167              
168             # The "name" case sensitivity depends on the server implementation:
169             # - Azure ML: it is case sensitive and must be kept as-is in the package URL.
170             # - Databricks: it is case insensitive and must be lowercased in the package URL.
171              
172 27 50       96 last TYPE unless $components{qualifiers}->{repository_url};
173              
174 27 100       123 if ($components{qualifiers}->{repository_url} =~ /azuredatabricks/) {
175 17         111 $components{name} = lc $components{name};
176             }
177 27         70 last TYPE;
178             }
179              
180 1572 100       5256 if (/pypi/) {
181              
182             # A PyPI package name must be lowercased and underscore "_" replaced with a dash "-".
183 21         64 $components{name} =~ s/_/-/g;
184 21         55 last TYPE;
185             }
186              
187 1551 100       5174 if (/cpan/) {
188 1011 50       2519 if (defined $components{qualifiers}->{author}) {
189              
190             # CPAN ID. It MUST be written uppercase.
191 0         0 $components{qualifiers}->{author} = uc $components{qualifiers}->{author};
192             }
193 1011         2642 last TYPE;
194             }
195              
196             }
197              
198 1626 50       17669 return wantarray ? %components : \%components;
199              
200             }
201              
202             sub validate {
203              
204 1626     1626 1 3244 my $self = shift;
205              
206 1626         10973 my %components = (
207             type => undef,
208             namespace => undef,
209             name => undef,
210             version => undef,
211             version => undef,
212             qualifiers => {},
213             subpath => undef,
214             @_
215             );
216              
217 1626         3866 my $purl_type = $components{type};
218              
219              
220             # Check PURL components requirements
221              
222 1626 50       4403 Carp::croak "Invalid PURL: '$components{scheme}' is not a valid scheme" unless ($components{scheme} eq 'pkg');
223              
224 1626         2643 foreach my $qualifier (keys %{$components{qualifiers}}) {
  1626         5252  
225 1378 100       10144 Carp::croak "Invalid PURL: '$qualifier' is not a valid qualifier" if ($qualifier =~ /(\s|\%)/);
226             }
227              
228             # Check checksum qualifier
229 1621 100 66     9669 if (defined $components{qualifiers}->{checksum} and ref $components{qualifiers}->{checksum} eq 'ARRAY') {
230              
231 7         15 foreach (@{$components{qualifiers}->{checksum}}) {
  7         30  
232              
233 10         41 my ($algo, $checksum) = split ':', $_;
234              
235 10 50       41 if (defined $ALGO_LENGTH{$algo}) {
    0          
236              
237 10 50       32 if (length($checksum) != $ALGO_LENGTH{$algo}) {
238 10         18 DEBUG and say STDERR "-- Malformed '$algo' checksum qualifier (invalid length)";
239             }
240              
241 10 100       61 if ($checksum !~ m/^[0-9a-f]+$/) {
242 1         4 DEBUG and say STDERR "-- Malformed '$algo' checksum qualifier (invalid characters)";
243             }
244              
245             }
246              
247             # Fallback
248             elsif ($checksum !~ /^[0-9a-f]{32,}$/) {
249 0         0 DEBUG and say STDERR "-- Malformed '$algo' checksum qualifier (invalid characters or length)";
250             }
251              
252             }
253              
254             }
255              
256             # PURL type definition validation
257 1621 100       2459 if (%{$self->definition}) {
  1621         4088  
258              
259             # Check components using PURL type definition
260              
261 1614         3350 for my $component (qw[namespace name version subpath]) {
262              
263 6360 100       12374 next unless $self->component_have_definition($component);
264              
265 4824         11301 my $requirement = $self->component_requirement($component);
266 4824 100       9910 next unless $requirement;
267              
268 4815         6857 DEBUG and say STDERR "-- Validation - $component is $requirement";
269              
270 4815 100 100     14715 if (defined $components{$component} && $self->component_is_prohibited($component)) {
271 2         657 Carp::croak sprintf("Invalid PURL: '%s' is prohibited for '%s' PURL type", $component, $purl_type);
272             }
273              
274 4813 100 100     13735 if (!defined $components{$component} && $self->component_is_required($component)) {
275 30         7177 Carp::croak sprintf("Invalid PURL: '%s' is required for '%s' PURL type", $component, $purl_type);
276             }
277              
278             }
279              
280             # Default known qualifiers
281             # TODO: "checksums" legacy qualifier
282 1582         5956 my @known_qualifiers = (qw[
283             vers
284             repository_url
285             download_url
286             vcs_url
287             file_name
288             checksum
289             checksums
290             ]);
291              
292 1582         2472 foreach my $rule (@{$self->qualifiers_definition}) {
  1582         4189  
293              
294 4682         9045 my $key = $rule->{key};
295 4682         7790 push @known_qualifiers, $key;
296              
297 4682         6917 my $requirement = $rule->{requirement};
298 4682 100       10269 next unless $requirement;
299              
300 4440         5280 DEBUG and say STDERR "-- Validation - '$key' qualifier is $requirement";
301              
302 4440 50 66     10352 if (defined $components{qualifiers}->{$key} and $requirement eq 'prohibited') {
303 0         0 Carp::croak sprintf("Invalid PURL: '%s' qualifier is prohibited for '%s' PURL type", $key, $purl_type);
304             }
305              
306 4440 100 100     15508 if (not defined $components{qualifiers}->{$key} and $requirement eq 'required') {
307 1         215 Carp::croak sprintf("Invalid PURL: '%s' qualifier is required for '%s' PURL type", $key, $purl_type);
308             }
309              
310             }
311              
312             # Check unknown qualifiers
313 1581         2948 foreach my $key (keys %{$components{qualifiers}}) {
  1581         4588  
314             DEBUG and say STDERR "-- '$key' is known qualifier for '$purl_type' PURL type"
315 1373 100   13378   10601 unless (first { $key eq $_ } @known_qualifiers);
  13378         22405  
316             }
317              
318             }
319              
320              
321             # PURL type specific validation
322              
323 1588         4008 TYPE: for ($purl_type) {
324              
325 1588 100       4285 if (/conan/) {
326 29 100 100     98 if (!$components{namespace} && defined $components{qualifiers}->{channel}) {
327 3         768 Carp::croak "Invalid PURL: Conan 'channel' qualifier without 'namespace'";
328             }
329 26         51 last TYPE;
330             }
331              
332 1559 100       5389 if (/cpan/) {
333              
334             # Use legacy CPAN PURL type SPEC
335 997 100       3123 if ($ENV{PURL_LEGACY_CPAN_TYPE}) {
336              
337 6 50 33     43 if ((defined $components{namespace} && defined $components{name}) && $components{namespace} =~ /\:/) {
      33        
338 0         0 Carp::croak "Invalid PURL: CPAN 'namespace' component must have the distribution author";
339             }
340              
341 6 100 33     92 if ((defined $components{namespace} && defined $components{name}) && $components{name} =~ /\:/) {
      66        
342 3         730 Carp::croak "Invalid PURL: CPAN 'name' component must have the distribution name";
343             }
344              
345 3 50 33     10 if (!defined $components{namespace} && $components{name} =~ /\-/) {
346 0         0 Carp::croak "Invalid PURL: CPAN 'name' component must have the module name";
347             }
348              
349 3         6 last TYPE;
350              
351             }
352              
353 991 100       3749 if ($components{name} =~ /\:/) {
354 2         431 Carp::croak "Invalid PURL: The CPAN 'name' component must have the distribution name";
355             }
356              
357 989         2154 last TYPE;
358              
359             }
360              
361 562 100       1483 if (/cran/) {
362 19 100       762 Carp::croak "Invalid PURL: Cran 'version' is required" unless defined $components{version};
363 16         36 last TYPE;
364             }
365              
366 543 100       1560 if (/swift/) {
367              
368             # TODO remove after spec FIX
369 23 100       1283 Carp::croak "Invalid PURL: Swift 'version' is required" unless defined $components{version};
370              
371 17 50       53 if (defined $components{namespace}) {
372 17         88 my ($source, $user_org) = split '/', $components{namespace};
373 17 100       982 Carp::croak "Invalid PURL: Swift user/organization is required in 'namespace'" unless $user_org;
374             }
375              
376 13         33 last TYPE;
377              
378             }
379              
380             }
381              
382              
383 1567         6787 return 1;
384              
385             }
386              
387             1;
388              
389             __END__