File Coverage

blib/lib/URI/PackageURL.pm
Criterion Covered Total %
statement 124 127 97.6
branch 55 60 91.6
condition 7 10 70.0
subroutine 23 24 95.8
pod 13 14 92.8
total 222 235 94.4


line stmt bran cond sub pod time code
1             package URI::PackageURL;
2              
3 6     6   142301 use feature ':5.10';
  6         40  
  6         719  
4 6     6   62 use strict;
  6         11  
  6         131  
5 6     6   3049 use utf8;
  6         77  
  6         44  
6 6     6   208 use warnings;
  6         13  
  6         186  
7              
8 6     6   36 use Carp;
  6         29  
  6         408  
9 6     6   40 use Exporter qw(import);
  6         16  
  6         203  
10 6     6   2730 use URI::PackageURL::Util qw(purl_to_urls);
  6         54  
  6         461  
11              
12 6     6   2624 use overload '""' => 'to_string', fallback => 1;
  6         2103  
  6         71  
13              
14             our $VERSION = '2.02';
15             our @EXPORT = qw(encode_purl decode_purl);
16              
17             my $PURL_REGEXP = qr{^pkg:[A-Za-z\\.\\-\\+][A-Za-z0-9\\.\\-\\+]*/.+};
18              
19             sub new {
20              
21 62     62 1 26251 my ($class, %params) = @_;
22              
23 62   100     270 my $scheme = delete $params{scheme} // 'pkg';
24 62 100       482 my $type = delete $params{type} or Carp::croak "Invalid PackageURL: 'type' component is required";
25 60         102 my $namespace = delete $params{namespace};
26 60 100       544 my $name = delete $params{name} or Carp::croak "Invalid PackageURL: 'name' component is required";
27 56         93 my $version = delete $params{version};
28 56   100     191 my $qualifiers = delete $params{qualifiers} // {};
29 56         96 my $subpath = delete $params{subpath};
30              
31 56         108 $type = lc $type;
32              
33 56 100       113 if (grep { $_ eq $type } qw(alpm apk bitbucket composer deb github gitlab hex npm oci pypi)) {
  616         1058  
34 13         33 $name = lc $name;
35             }
36              
37 56 100       109 if ($namespace) {
38 37 100       54 if (grep { $_ eq $type } qw(alpm apk bitbucket composer deb github gitlab golang hex rpm)) {
  370         603  
39 12         25 $namespace = lc $namespace;
40             }
41             }
42              
43 56         77 foreach my $qualifier (keys %{$qualifiers}) {
  56         168  
44 31 100       213 Carp::croak "Invalid PackageURL: '$qualifier' is not a valid qualifier" if ($qualifier =~ /\s/);
45             }
46              
47 55 100       118 $name =~ s/_/-/g if $type eq 'pypi';
48 55 100       114 $name =~ s/::/-/g if $type eq 'cpan';
49              
50 55 100       132 if ($type eq 'swift') {
51 3 100       116 Carp::croak "Invalid PackageURL: Swift 'version' is required" unless defined $version;
52 2 100       109 Carp::croak "Invalid PackageURL: Swift 'namespace' is required" unless defined $namespace;
53             }
54              
55 53 100       101 if ($type eq 'cran') {
56 2 100       110 Carp::croak "Invalid PackageURL: Cran 'version' is required" unless defined $version;
57             }
58              
59 52 100       98 if ($type eq 'conan') {
60              
61 4 100 66     15 if ($namespace && $namespace ne '') {
62 2 100       8 if (!defined $qualifiers->{channel}) {
63 1         115 Carp::croak "Invalid PackageURL: Conan 'channel' qualifier does not exist for namespace '$namespace'";
64             }
65             }
66             else {
67 2 100       5 if (defined $qualifiers->{channel}) {
68 1         108 Carp::croak "Invalid PackageURL: Conan 'namespace' does not exist for channel '$qualifiers->{channel}'";
69             }
70             }
71              
72             }
73              
74 50         200 my $self = {
75             scheme => $scheme,
76             type => $type,
77             namespace => $namespace,
78             name => $name,
79             version => $version,
80             qualifiers => $qualifiers,
81             subpath => $subpath
82             };
83              
84 50         218 return bless $self, $class;
85              
86             }
87              
88 0     0 1 0 sub scheme { shift->{scheme} }
89 66     66 1 369 sub type { shift->{type} }
90 81     81 1 216 sub namespace { shift->{namespace} }
91 55     55 1 138 sub name { shift->{name} }
92 88     88 1 226 sub version { shift->{version} }
93 59     59 1 175 sub qualifiers { shift->{qualifiers} }
94 44     44 1 129 sub subpath { shift->{subpath} }
95              
96             sub encode_purl {
97 1     1 1 94 return URI::PackageURL->new(@_)->to_string;
98             }
99              
100             sub decode_purl {
101 4     4 1 8973 return URI::PackageURL->from_string(shift);
102             }
103              
104             sub from_string {
105              
106 15     15 0 40 my ($class, $string) = @_;
107              
108 15 50       139 if ($string !~ /$PURL_REGEXP/) {
109 0         0 Carp::croak 'Malformed PackageURL string';
110             }
111              
112 15         32 my %components = ();
113              
114              
115             # Split the purl string once from right on '#'
116             # The left side is the remainder
117             # Strip the right side from leading and trailing '/'
118             # Split this on '/'
119             # Discard any empty string segment from that split
120             # Discard any '.' or '..' segment from that split
121             # Percent-decode each segment
122             # UTF-8-decode each segment if needed in your programming language
123             # Join segments back with a '/'
124             # This is the subpath
125              
126 15         49 my @s1 = split('#', $string);
127              
128 15 100       37 if ($s1[1]) {
129 1         7 $s1[1] =~ s{(^\/|\/$)}{};
130 1 50 33     4 my @subpath = map { _url_decode($_) } grep { $_ ne '' && $_ ne '.' && $_ ne '..' } split /\//, $s1[1];
  3         6  
  3         17  
131 1         5 $components{subpath} = join '/', @subpath;
132             }
133              
134             # Split the remainder once from right on '?'
135             # The left side is the remainder
136             # The right side is the qualifiers string
137             # Split the qualifiers on '&'. Each part is a key=value pair
138             # For each pair, split the key=value once from left on '=':
139             # The key is the lowercase left side
140             # The value is the percent-decoded right side
141             # UTF-8-decode the value if needed in your programming language
142             # Discard any key/value pairs where the value is empty
143             # If the key is checksums, split the value on ',' to create a list of checksums
144             # This list of key/value is the qualifiers object
145              
146 15         33 my @s2 = split(/\?/, $s1[0]);
147              
148 15 100       34 if ($s2[1]) {
149              
150 4         13 my @qualifiers = split('&', $s2[1]);
151              
152 4         10 foreach my $qualifier (@qualifiers) {
153              
154 5         15 my ($key, $value) = split('=', $qualifier);
155 5         17 $value = _url_decode($value);
156              
157 5 50       14 if ($key eq 'checksums') {
158 0         0 $value = [split(',', $value)];
159             }
160              
161 5         17 $components{qualifiers}->{$key} = $value;
162              
163             }
164              
165             }
166              
167              
168             # Split the remainder once from left on ':'
169             # The left side lowercased is the scheme
170             # The right side is the remainder
171              
172 15         38 my @s3 = split(':', $s2[0], 2);
173 15         56 $components{scheme} = lc $s3[0];
174              
175              
176             # Strip the remainder from leading and trailing '/'
177             # Split this once from left on '/'
178             # The left side lowercased is the type
179             # The right side is the remainder
180              
181 15         105 $s3[1] =~ s{(^\/|\/$)}{};
182 15         33 my @s4 = split('/', $s3[1], 2);
183 15         33 $components{type} = lc $s4[0];
184              
185              
186             # Split the remainder once from right on '@'
187             # The left side is the remainder
188             # Percent-decode the right side. This is the version.
189             # UTF-8-decode the version if needed in your programming language
190             # This is the version
191              
192 15         32 my @s5 = split('@', $s4[1]);
193 15 50       44 $components{version} = _url_decode($s5[1]) if ($s5[1]);
194              
195              
196             # Split the remainder once from right on '/'
197             # The left side is the remainder
198             # Percent-decode the right side. This is the name
199             # UTF-8-decode this name if needed in your programming language
200             # Apply type-specific normalization to the name if needed
201             # This is the name
202              
203 15         34 my @s6 = split('/', $s5[0], 2);
204 15 100       47 $components{name} = (scalar @s6 > 1) ? _url_decode($s6[1]) : _url_decode($s6[0]);
205              
206              
207             # Split the remainder on '/'
208             # Discard any empty segment from that split
209             # Percent-decode each segment
210             # UTF-8-decode the each segment if needed in your programming language
211             # Apply type-specific normalization to each segment if needed
212             # Join segments back with a '/'
213             # This is the namespace
214              
215 15 100       36 if (scalar @s6 > 1) {
216 11         28 my @s7 = split('/', $s6[0]);
217 11         63 $components{namespace} = join '/', map { _url_decode($_) } @s7;
  11         19  
218             }
219              
220 15         70 return $class->new(%components);
221              
222             }
223              
224             sub to_string {
225              
226 39     39 1 401 my $self = shift;
227              
228 39         81 my @purl = ('pkg', ':', $self->type, '/');
229              
230             # Namespace
231 39 100       72 if ($self->namespace) {
232              
233 28         51 my @ns = map { _url_encode($_) } split(/\//, $self->namespace);
  29         56  
234 28         138 push @purl, (join('/', @ns), '/');
235              
236             }
237              
238             # Name
239 39         78 push @purl, _url_encode($self->name);
240              
241             # Version
242 39 100       114 push @purl, ('@', _url_encode($self->version)) if ($self->version);
243              
244             # Qualifiers
245 39 50       81 if (my $qualifiers = $self->qualifiers) {
246              
247 39         61 my @qualifiers = map { sprintf('%s=%s', $_, _url_encode($qualifiers->{$_})) } sort keys %{$qualifiers};
  27         52  
  39         109  
248 39 100       117 push @purl, ('?', join('&', @qualifiers)) if (@qualifiers);
249              
250             }
251              
252             # Subpath
253 39 100       74 push @purl, ('#', $self->subpath) if ($self->subpath);
254              
255 39         159 return join '', @purl;
256              
257             }
258              
259             sub to_urls {
260 1     1 1 4 purl_to_urls(shift);
261             }
262              
263             sub TO_JSON {
264              
265 1     1 1 4 my $self = shift;
266              
267             return {
268 1         3 type => $self->type,
269             name => $self->name,
270             version => $self->version,
271             namespace => $self->namespace,
272             qualifiers => $self->qualifiers,
273             subpath => $self->subpath,
274             };
275              
276             }
277              
278             sub _url_encode {
279              
280 129     129   184 my $string = shift;
281              
282             # RFC-3986 (but exclude "/" and ":")
283 129         270 $string =~ s/([^A-Za-z0-9\-._~\/:])/sprintf '%%%02X', ord $1/ge;
  3         19  
284 129         315 return $string;
285              
286             }
287              
288             sub _url_decode {
289 49     49   76 my $string = shift;
290 49         86 $string =~ s/%([0-9a-fA-F]{2})/chr hex $1/ge;
  1         8  
291 49         131 return $string;
292             }
293              
294             1;
295              
296             __END__