File Coverage

blib/lib/URI/PackageURL.pm
Criterion Covered Total %
statement 156 163 95.7
branch 44 54 81.4
condition 17 24 70.8
subroutine 31 34 91.1
pod 19 19 100.0
total 267 294 90.8


line stmt bran cond sub pod time code
1             package URI::PackageURL;
2              
3 9     9   1346513 use feature ':5.10';
  9         43  
  9         1647  
4 9     9   65 use strict;
  9         20  
  9         251  
5 9     9   3684 use utf8;
  9         2272  
  9         57  
6 9     9   327 use warnings;
  9         27  
  9         463  
7              
8 9     9   52 use Carp ();
  9         29  
  9         221  
9 9     9   38 use Exporter qw(import);
  9         23  
  9         356  
10              
11 9     9   5351 use URI::PackageURL::Type;
  9         30  
  9         480  
12 9     9   5292 use URI::PackageURL::Util qw(purl_to_urls);
  9         31  
  9         835  
13              
14 9     9   700 BEGIN { *PURL:: = *URI::PackageURL:: }
15              
16 9     9   99 use constant DEBUG => $ENV{PURL_DEBUG};
  9         20  
  9         874  
17              
18 9     9   62 use overload '""' => 'to_string', fallback => 1;
  9         16  
  9         74  
19              
20             our $VERSION = '2.25';
21             our @EXPORT = qw(encode_purl decode_purl);
22              
23             my $PURL_REGEXP = qr{^pkg:(([/]{1,})?)([A-Za-z][A-Za-z0-9\.\-]*)([/]{1,}).+};
24              
25             sub new {
26              
27 1645     1645 1 4646012 my ($class, %params) = @_;
28              
29 1645 100       8477 my $type = delete $params{type} or Carp::croak "Invalid PURL: 'type' component is required";
30 1636         3807 my $namespace = delete $params{namespace};
31 1636 100       6702 my $name = delete $params{name} or Carp::croak "Invalid PURL: 'name' component is required";
32 1626         3610 my $version = delete $params{version};
33 1626   100     6379 my $qualifiers = delete $params{qualifiers} // {};
34 1626         3318 my $subpath = delete $params{subpath};
35              
36 1626   50     6790 my $validate = delete $params{validate} // 1;
37              
38 1626         8242 my $purl_definition = URI::PackageURL::Type->new($type);
39              
40 1626         9601 my %components = $purl_definition->normalize(
41             scheme => 'pkg', # The scheme is a constant with the value "pkg".
42             type => $type,
43             namespace => $namespace,
44             name => $name,
45             version => $version,
46             qualifiers => $qualifiers,
47             subpath => $subpath,
48             );
49              
50 1626 50       10365 $purl_definition->validate(%components) if $validate;
51              
52 1567         9258 my $self = {components => \%components, definition => $purl_definition};
53              
54 1567         11133 return bless $self, $class;
55              
56             }
57              
58 1859     1859 1 7749 sub definition { shift->{definition} }
59              
60 0     0 1 0 sub scheme {'pkg'} # The scheme is a constant with the value "pkg".
61 6336     6336 1 18794 sub type { shift->_component('type', @_) }
62 6679     6679 1 226245 sub namespace { shift->_component('namespace', @_) }
63 4494     4494 1 181383 sub name { shift->_component('name', @_) }
64 6817     6817 1 212096 sub version { shift->_component('version', @_) }
65 7117     7117 1 78242 sub qualifiers { shift->_component('qualifiers', @_) }
66 2637     2637 1 191481 sub subpath { shift->_component('subpath', @_) }
67              
68 5     5 1 231574 sub encode_purl { __PACKAGE__->new(@_)->to_string }
69 6     6 1 248993 sub decode_purl { __PACKAGE__->from_string(shift) }
70              
71             sub clone {
72 927     927 1 2038 my $self = shift;
73 927         5137 bless {%$self}, ref $self;
74             }
75              
76 1     1 1 5 sub to_urls { purl_to_urls(shift) }
77 0     0 1 0 sub download_url { shift->to_urls->{download} }
78 0     0 1 0 sub repository_url { shift->to_urls->{repository} }
79              
80             sub from_string {
81              
82 499     499 1 2580268 my ($class, $string) = @_;
83              
84 499         957 DEBUG and say STDERR "-- INPUT: $string";
85 499         812 DEBUG and say STDERR "-- REGEXP: $PURL_REGEXP";
86              
87             # Strip slash / after scheme
88 499         2334 while ($string =~ m{^pkg:/}) {
89 20         91 $string =~ s{^pkg:/}{pkg:};
90             }
91              
92 499 100       7550 if ($string !~ /$PURL_REGEXP/) {
93 26         3590 Carp::croak 'Malformed PURL string';
94             }
95              
96 473         1304 my %components = ();
97              
98              
99             # Split the purl string once from right on '#'
100             # The left side is the 'remainder'
101             # Strip the right side from leading and trailing '/'
102             # Split this on '/'
103             # Discard any empty string segment from that split
104             # Discard any '.' or '..' segment from that split
105             # Percent-decode each segment
106             # UTF-8-decode each segment if needed in your programming language
107             # Join segments back with a '/'
108             # This is the 'subpath'
109              
110 473         1836 my @s1 = split(/#([^#]+)$/, $string);
111              
112 473 100       1382 if ($s1[1]) {
113 43         263 $s1[1] =~ s/(^\/|\/$)//;
114 43 50 33     172 my @subpath = map { _url_decode($_) } grep { $_ ne '' && $_ ne '.' && $_ ne '..' } split /\//, $s1[1];
  103         196  
  103         611  
115 43         237 $components{subpath} = join '/', @subpath;
116             }
117              
118              
119             # Split the 'remainder' once from right on '?'
120             # The left side is the 'remainder'
121             # The right side is the 'qualifiers' string
122             # Split the 'qualifiers' on '&'. Each part is a 'key=value' pair
123             # For each pair, split the 'key=value' once from left on '=':
124             # The 'key' is the lowercase left side
125             # The 'value' is the percent-decoded right side
126             # UTF-8-decode the value if needed in your programming language
127             # Discard any key/value pairs where the value is empty
128             # If the 'key' is 'checksum', split the 'value' on ',' to create a list of checksum
129             # This list of key/value is the 'qualifiers' object
130              
131 473         1749 my @s2 = split(/\?([^\?]+)$/, $s1[0]);
132              
133 473 100       1417 if ($s2[1]) {
134              
135 192         720 my @qualifiers = split('&', $s2[1]);
136              
137 192         614 foreach my $qualifier (@qualifiers) {
138              
139 310         1785 my ($key, $value) = ($qualifier =~ /^([^=]+)(?:=(.*))?$/);
140 310         864 $value = _url_decode($value);
141              
142 310 100 66     1657 if ($key eq 'checksums' || $key eq 'checksum') {
143 5 50       21 Carp::carp "Detected 'checksums' qualifier. Use 'checksum' qualifier instead." if ($key eq 'checksums');
144 5         21 $value = [split(',', $value)];
145             }
146              
147 310         1495 $components{qualifiers}->{lc $key} = $value;
148              
149             }
150              
151             }
152              
153              
154             # Split the 'remainder' once from left on ':'
155             # The left side lowercased is the 'scheme'
156             # The right side is the 'remainder'
157              
158 473         1381 my @s3 = split(':', $s2[0], 2);
159              
160 473 50       1233 Carp::croak 'Invalid PURL: Missing "scheme"' unless $s3[0];
161 473 50       1185 Carp::croak 'Invalid PURL' unless $s3[1];
162              
163 473         1704 $components{scheme} = lc $s3[0];
164              
165             # Strip all leading '/' characters (e.g., '/', '//', '///' and so on) from the 'remainder'
166             # Split this once from left on '/'
167             # The left side lowercased is the 'type'
168             # The right side is the 'remainder'
169              
170 473         1703 while ($s3[1] =~ m|^//|) {
171 0         0 $s3[1] =~ s|^//|/|;
172             }
173              
174 473         1085 $s3[1] =~ s|^/||; # Strip leading '/' character
175              
176 473         1284 my @s4 = split('/', $s3[1], 2);
177 473         1372 $components{type} = lc $s4[0];
178              
179 473 50       2519 Carp::croak 'Invalid PURL: Invalid "type"' if $components{type} !~ /^[a-z][a-z0-9.-]+$/;
180 473 50       1088 Carp::croak 'Invalid PURL' unless $s4[1];
181              
182              
183             # Split the 'remainder' once from right on '@'
184             # The left side is the 'remainder'
185             # Percent-decode the right side. This is the 'version'.
186             # UTF-8-decode the 'version' if needed in your programming language
187             # This is the 'version'
188              
189 473         2007 my @s5 = split(/@([^@]+)$/, $s4[1]);
190              
191             # NPM purl MAY have a namespace starting with "@"
192             # so we need to handle this case separately
193              
194 473 100 100     1905 if ($components{type} eq 'npm' and $s4[1] =~ /^@/ and $s4[1] !~ /@.*@/) {
      66        
195 2         7 @s5 = ($s4[1]);
196             }
197              
198 473 100       2538 $components{version} = _url_decode($s5[1]) if ($s5[1]);
199              
200              
201             # Strip all trailing '/' characters (e.g., '/', '//', '///' and so on) from the 'remainder'
202             # The left side is the 'remainder'
203             # Percent-decode the right side. This is the 'name'
204             # UTF-8-decode this 'name' if needed in your programming language
205             # Apply type-specific normalization to the 'name' if needed
206             # This is the 'name'
207              
208 473         1494 while ($s5[0] =~ m|//$|) {
209 0         0 $s5[0] =~ s|//$|/|;
210             }
211              
212 473         996 $s5[0] =~ s|/$||; # Strip trailing '/' character
213              
214 473         1331 my @s6 = split('/', $s5[0], -1);
215 473         1048 $components{name} = _url_decode(pop @s6);
216              
217 473 100       2970 Carp::croak 'Invalid PURL: Missing "name"' unless $components{name};
218              
219              
220             # Split the 'remainder' on '/'
221             # Discard any empty segment from that split
222             # Percent-decode each segment
223             # UTF-8-decode the each segment if needed in your programming language
224             # Apply type-specific normalization to each segment if needed
225             # Join segments back with a '/'
226             # This is the 'namespace'
227              
228 464 100       1469 if (@s6) {
229 261         665 $components{namespace} = join '/', map { _url_decode($_) } @s6;
  282         600  
230             }
231              
232              
233 464         721 if (DEBUG) {
234             say STDERR "-- S1: @s1";
235             say STDERR "-- S2: @s2";
236             say STDERR "-- S3: @s3";
237             say STDERR "-- S4: @s4";
238             say STDERR "-- S5: @s5";
239             say STDERR "-- S6: @s6";
240             }
241              
242 464         2276 return $class->new(%components);
243              
244             }
245              
246             sub to_string {
247              
248 2373     2373 1 82805 my $self = shift;
249              
250 2373         5959 my @purl = ('pkg', ':', $self->type, '/');
251              
252             # Namespace
253 2373 100       5292 if ($self->namespace) {
254              
255 2190         4851 my @ns = map { _url_encode($_) } split(/\//, $self->namespace);
  2209         4322  
256 2190         7883 push @purl, (join('/', @ns), '/');
257              
258             }
259              
260             # Name
261 2373         5665 push @purl, _encode($self->name);
262              
263             # Version
264 2373 100       5245 push @purl, ('@', _encode($self->version)) if ($self->version);
265              
266             # Qualifiers
267 2373 50       5674 if (my $qualifiers = $self->qualifiers) {
268              
269             # TODO: Legacy 'checksums' qualifier will be dropped in the future
270 2373         4837 foreach (qw[checksum checksums]) {
271 4746 100 100     13063 if (defined $qualifiers->{$_} && ref $qualifiers->{$_} eq 'ARRAY') {
272 5         11 $qualifiers->{$_} = join ',', @{$qualifiers->{$_}};
  5         26  
273             }
274             }
275              
276             # TODO: Use URI::VersionRange during qualifiers decode ?
277 2373 50 66     6631 if (defined $qualifiers->{vers} && ref $qualifiers->{vers} eq 'URI::VersionRange') {
278 0         0 $qualifiers->{vers} = $qualifiers->{vers}->to_string;
279 0         0 say STDERR $qualifiers->{vers};
280             }
281              
282 2194         5305 my @qualifiers = map { sprintf('%s=%s', lc $_, _encode($qualifiers->{$_})) }
283 2373         3603 grep { $qualifiers->{$_} } sort keys %{$qualifiers};
  2194         5628  
  2373         8175  
284              
285 2373 100       9195 push @purl, ('?', join('&', @qualifiers)) if (@qualifiers);
286              
287             }
288              
289             # Subpath
290 2373 100       5118 if ($self->subpath) {
291              
292 49         120 my $subpath = $self->subpath;
293              
294 49         124 $subpath =~ s{\.\./}{};
295 49         181 $subpath =~ s{\./}{};
296              
297 49         277 my @subpath = map { _encode($_) } split '/', $subpath;
  113         190  
298 49         206 push @purl, ('#', join('/', @subpath));
299              
300             }
301              
302 2373         20383 return join '', @purl;
303              
304             }
305              
306             sub to_hash {
307              
308 1     1 1 2 my $self = shift;
309              
310 1         3 my %hash = map { $_ => $self->{components}->{$_} } qw[scheme type name version namespace qualifiers subpath];
  7         15  
311 1         3 return \%hash;
312              
313             }
314              
315 1     1 1 186 sub TO_JSON { shift->to_hash }
316              
317             sub _component {
318              
319 34080     34080   58616 my ($self, $component, $value) = @_;
320              
321 34080 100       62821 if (@_ == 3) {
322 927         2779 $self->{components}->{$component} = $value;
323             }
324              
325 34080         121136 return $self->{components}->{$component};
326              
327             }
328              
329             sub _url_encode {
330              
331 9214     9214   19438 my ($string, $pattern) = @_;
332              
333             # RFC-3986
334 9214 50 50     35538 $pattern //= '^A-Za-z0-9\-._~/' unless $pattern;
335 9214         37673 $string =~ s/([$pattern])/sprintf '%%%02X', ord $1/ge;
  199         1367  
336 9214         20396 return $string;
337              
338             }
339              
340             sub _encode {
341              
342 7005     7005   10591 my $string = shift;
343              
344 7005         11036 $string = _url_encode($string);
345              
346 7005         13592 $string =~ s{%3A}{:}g;
347 7005         15193 $string =~ s{/}{%2F}g;
348              
349 7005         20538 return $string;
350              
351             }
352              
353             sub _url_decode {
354              
355 1588     1588   2779 my $string = shift;
356 1588 100       3003 return unless $string;
357              
358 1579         3886 $string =~ s/%([0-9a-fA-F]{2})/chr hex $1/ge;
  70         360  
359 1579         4626 return $string;
360              
361             }
362              
363             1;
364              
365             __END__