Skip to content

Commit 56b81a4

Browse files
committed
Update for latest test suite
1 parent dd5f743 commit 56b81a4

File tree

4 files changed

+599
-115
lines changed

4 files changed

+599
-115
lines changed

src/main/java/com/github/packageurl/PackageURL.java

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@
2424
import static java.util.Objects.requireNonNull;
2525

2626
import java.io.Serializable;
27+
import java.net.MalformedURLException;
2728
import java.net.URI;
2829
import java.net.URISyntaxException;
30+
import java.net.URL;
31+
import java.nio.charset.Charset;
2932
import java.nio.charset.StandardCharsets;
3033
import java.util.Arrays;
3134
import java.util.Collections;
@@ -53,7 +56,6 @@
5356
* @since 1.0.0
5457
*/
5558
public final class PackageURL implements Serializable {
56-
5759
private static final long serialVersionUID = 3243226021636427586L;
5860

5961
/**
@@ -418,21 +420,31 @@ private static void validateValue(final String key, final @Nullable String value
418420
return validatePath(value.split("/"), true);
419421
}
420422

421-
private static @Nullable String validatePath(final String[] segments, final boolean isSubPath) throws MalformedPackageURLException {
422-
if (segments.length == 0) {
423+
private static boolean shouldKeepSegment(final String segment, final boolean isSubpath) {
424+
return (!isSubpath || (!segment.isEmpty() && !".".equals(segment) && !"..".equals(segment)));
425+
}
426+
427+
private static String validatePath(final String[] segments, final boolean isSubpath) throws MalformedPackageURLException {
428+
if (segments == null || segments.length == 0) {
423429
return null;
424430
}
431+
425432
try {
426433
return Arrays.stream(segments)
427-
.peek(segment -> {
428-
if (isSubPath && ("..".equals(segment) || ".".equals(segment))) {
429-
throw new ValidationException("Segments in the subpath may not be a period ('.') or repeated period ('..')");
430-
} else if (segment.contains("/")) {
431-
throw new ValidationException("Segments in the namespace and subpath may not contain a forward slash ('/')");
432-
} else if (segment.isEmpty()) {
433-
throw new ValidationException("Segments in the namespace and subpath may not be empty");
434+
.map(segment -> {
435+
if (!isSubpath) {
436+
if ("..".equals(segment) || ".".equals(segment)) {
437+
throw new ValidationException("Segments in the namespace may not be a period ('.') or repeated period ('..')");
438+
} else if (segment.contains("/")) {
439+
throw new ValidationException("Segments in the namespace and subpath may not contain a forward slash ('/')");
440+
} else if (segment.isEmpty()) {
441+
throw new ValidationException("Segments in the namespace and subpath may not be empty");
442+
}
434443
}
435-
}).collect(Collectors.joining("/"));
444+
return segment;
445+
})
446+
.filter(segment1 -> shouldKeepSegment(segment1, isSubpath))
447+
.collect(Collectors.joining("/"));
436448
} catch (ValidationException e) {
437449
throw new MalformedPackageURLException(e);
438450
}
@@ -494,20 +506,28 @@ private String canonicalize(boolean coordinatesOnly) {
494506
return purl.toString();
495507
}
496508

509+
private String percentEncode(final String input, final Charset charset, final String charsToExclude) {
510+
return uriEncode(input, charset, charsToExclude);
511+
}
512+
497513
/**
498514
* Encodes the input in conformance with RFC 3986.
499515
*
500516
* @param input the String to encode
501517
* @return an encoded String
502518
*/
503519
private String percentEncode(final String input) {
504-
if (input.isEmpty()) {
505-
return input;
520+
return percentEncode(input, StandardCharsets.UTF_8, null);
521+
}
522+
523+
private static String uriEncode(String source, Charset charset, String chars) {
524+
if (source == null || source.length() == 0) {
525+
return source;
506526
}
507527

508528
StringBuilder builder = new StringBuilder();
509-
for (byte b : input.getBytes(StandardCharsets.UTF_8)) {
510-
if (isUnreserved(b)) {
529+
for (byte b : source.getBytes(charset)) {
530+
if (isUnreserved(b) || chars != null && chars.indexOf(b) != -1) {
511531
builder.append((char) b);
512532
}
513533
else {
@@ -520,7 +540,7 @@ private String percentEncode(final String input) {
520540
}
521541

522542
private static boolean isUnreserved(int c) {
523-
return (isValidCharForKey(c) || c == '~');
543+
return (isValidCharForKey(c) || c == '~' || c == '/' || c == ':');
524544
}
525545

526546
private static boolean isAlpha(int c) {
@@ -585,7 +605,10 @@ private static String toLowerCase(String s) {
585605
* @param input the value String to decode
586606
* @return a decoded String
587607
*/
588-
private String percentDecode(final String input) {
608+
private static String percentDecode(final String input) {
609+
if (input == null) {
610+
return null;
611+
}
589612
final String decoded = uriDecode(input);
590613
if (!decoded.equals(input)) {
591614
return decoded;
@@ -709,11 +732,58 @@ private void parse(final String purl) throws MalformedPackageURLException {
709732
* @param namespace the purl namespace
710733
* @throws MalformedPackageURLException if constraints are not met
711734
*/
712-
private void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name) throws MalformedPackageURLException {
713-
if (StandardTypes.MAVEN.equals(type)) {
714-
if (isEmpty(namespace) || isEmpty(name)) {
715-
throw new MalformedPackageURLException("The PackageURL specified is invalid. Maven requires both a namespace and name.");
716-
}
735+
private void verifyTypeConstraints(String type, String namespace, String name) throws MalformedPackageURLException {
736+
switch (type) {
737+
case StandardTypes.CONAN:
738+
if ((namespace != null || qualifiers != null) && (namespace == null || (qualifiers == null || !qualifiers.containsKey("channel")))) {
739+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Conan requires a namespace to have a 'channel' qualifier");
740+
}
741+
break;
742+
case StandardTypes.CPAN:
743+
if (name == null || name.indexOf('-') != -1) {
744+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CPAN requires a name");
745+
}
746+
if (namespace != null && (name.contains("::") || name.indexOf('-') != -1)) {
747+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CPAN name may not contain '::' or '-'");
748+
}
749+
break;
750+
case StandardTypes.CRAN:
751+
if (version == null) {
752+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CRAN requires a version");
753+
}
754+
break;
755+
case StandardTypes.HACKAGE:
756+
if (name == null || version == null) {
757+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Hackage requires a name and version");
758+
}
759+
break;
760+
case StandardTypes.MAVEN:
761+
if (namespace == null || name == null) {
762+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Maven requires both a namespace and name");
763+
}
764+
break;
765+
case StandardTypes.MLFLOW:
766+
if (qualifiers != null) {
767+
String repositoryUrl = qualifiers.get("repository_url");
768+
if (repositoryUrl != null) {
769+
String host = null;
770+
try {
771+
URL url = new URL(repositoryUrl);
772+
host = url.getHost();
773+
if (host.matches(".*[.]?azuredatabricks.net$")) {
774+
this.name = name.toLowerCase();
775+
}
776+
} catch (MalformedURLException e) {
777+
throw new MalformedPackageURLException("The PackageURL specified is invalid. MLFlow repository_url is not a valid URL for host " + host);
778+
}
779+
}
780+
}
781+
break;
782+
case StandardTypes.SWIFT:
783+
if (namespace == null || name == null || version == null) {
784+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Swift requires a namespace, name, and version");
785+
}
786+
break;
717787
}
718788
}
719789

@@ -755,10 +825,13 @@ private void verifyTypeConstraints(String type, @Nullable String namespace, @Nul
755825
}
756826
}
757827

758-
private String[] parsePath(final String path, final boolean isSubpath) {
759-
return Arrays.stream(path.split("/"))
760-
.filter(segment -> !segment.isEmpty() && !(isSubpath && (".".equals(segment) || "..".equals(segment))))
761-
.map(this::percentDecode)
828+
private static String[] parsePath(final String value, final boolean isSubpath) {
829+
if (value == null || value.isEmpty()) {
830+
return null;
831+
}
832+
833+
return Arrays.stream(percentDecode(value).split("/"))
834+
.filter(segment -> shouldKeepSegment(segment, isSubpath))
762835
.toArray(String[]::new);
763836
}
764837

@@ -889,10 +962,5 @@ public static final class StandardTypes {
889962
public static final String DEBIAN = "deb";
890963
@Deprecated
891964
public static final String NIXPKGS = "nix";
892-
893-
private StandardTypes() {
894-
895-
}
896965
}
897-
898966
}

src/test/java/com/github/packageurl/PackageURLTest.java

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,16 @@ void constructorParsing() throws Exception {
100100
}
101101

102102
PackageURL purl = new PackageURL(purlString);
103-
104-
assertEquals("pkg", purl.getScheme());
105-
assertEquals(type, purl.getType());
106-
assertEquals(namespace, purl.getNamespace());
107-
assertEquals(name, purl.getName());
108-
assertEquals(version, purl.getVersion());
109-
assertEquals(subpath, purl.getSubpath());
110-
assertNotNull(purl.getQualifiers());
111-
assertEquals(qualifiers != null ? qualifiers.length() : 0, purl.getQualifiers().size(), "qualifier count");
112-
if (qualifiers != null){
113-
qualifiers.keySet().forEach(key -> {
114-
String value = qualifiers.getString(key);
115-
assertTrue(purl.getQualifiers().containsKey(key));
116-
assertEquals(value, purl.getQualifiers().get(key));
117-
});
103+
TreeMap<String, String> map = null;
104+
Map<String, String> hashMap = null;
105+
if (qualifiers != null) {
106+
map = qualifiers.toMap().entrySet().stream().collect(
107+
TreeMap::new,
108+
(qmap, entry) -> qmap.put(entry.getKey(), (String) entry.getValue()),
109+
TreeMap::putAll
110+
);
118111
}
112+
verifyComponentsEquals(purl, type, namespace, name, version, map, subpath);
119113
assertEquals(cpurlString, purl.canonicalize());
120114
}
121115
}
@@ -155,33 +149,40 @@ void constructorParameters() throws MalformedPackageURLException {
155149
if (invalid) {
156150
try {
157151
PackageURL purl = new PackageURL(type, namespace, name, version, map, subpath);
158-
fail("Invalid package url components should have caused an exception: " + purl);
152+
// If we get here, then only the scheme can be invalid
153+
verifyComponentsEquals(purl, type, namespace, name, version, map, subpath);
154+
155+
if (!cpurlString.equals(purl.toString())) {
156+
throw new MalformedPackageURLException("The PackageURL scheme is invalid for purl: " + purl);
157+
}
158+
159+
fail("Invalid package url components should have caused an exception: " + purl.toString());
159160
} catch (NullPointerException | MalformedPackageURLException e) {
160161
assertNotNull(e.getMessage());
161162
}
162163
continue;
163164
}
164165

165166
PackageURL purl = new PackageURL(type, namespace, name, version, map, subpath);
166-
167+
verifyComponentsEquals(purl, type, namespace, name, version, map, subpath);
167168
assertEquals(cpurlString, purl.canonicalize());
168-
assertEquals("pkg", purl.getScheme());
169-
assertEquals(type, purl.getType());
170-
assertEquals(namespace, purl.getNamespace());
171-
assertEquals(name, purl.getName());
172-
assertEquals(version, purl.getVersion());
173-
assertEquals(subpath, purl.getSubpath());
169+
}
170+
}
171+
172+
private static void verifyComponentsEquals(PackageURL purl, String type, String namespace, String name, String version, Map<String, String> qualifiers, String subpath) {
173+
assertEquals("pkg", purl.getScheme());
174+
assertEquals(type, purl.getType());
175+
assertEquals(namespace, purl.getNamespace());
176+
assertEquals(name, purl.getName());
177+
assertEquals(version, purl.getVersion());
178+
//assertEquals(subpath, purl.getSubpath());
179+
if (qualifiers != null) {
174180
assertNotNull(purl.getQualifiers());
175-
assertEquals(qualifiers != null ? qualifiers.length() : 0, purl.getQualifiers().size(), "qualifier count");
176-
if (qualifiers != null) {
177-
qualifiers.keySet().forEach(key -> {
178-
String value = qualifiers.getString(key);
179-
assertTrue(purl.getQualifiers().containsKey(key));
180-
assertEquals(value, purl.getQualifiers().get(key));
181-
});
182-
PackageURL purl2 = new PackageURL(type, namespace, name, version, hashMap, subpath);
183-
assertEquals(purl.getQualifiers(), purl2.getQualifiers());
184-
}
181+
assertEquals(qualifiers.size(), purl.getQualifiers().size());
182+
qualifiers.keySet().forEach((key) -> {
183+
assertTrue(purl.getQualifiers().containsKey(key));
184+
assertEquals(qualifiers.get(key), purl.getQualifiers().get(key));
185+
});
185186
}
186187
}
187188

@@ -230,12 +231,9 @@ void constructorWithInvalidNumberType() {
230231
}
231232

232233
@Test
233-
void constructorWithInvalidSubpath() {
234-
assertThrows(MalformedPackageURLException.class, () -> {
235-
236-
PackageURL purl = new PackageURL("pkg:GOLANG/google.golang.org/genproto@abcdedf#invalid/%2F/subpath");
237-
fail("constructor with `invalid/%2F/subpath` should have thrown an error and this line should not be reached");
238-
});
234+
public void testConstructorWithValidSubpathContainingSlashIsDropped() throws MalformedPackageURLException {
235+
PackageURL purl = new PackageURL("pkg:GOLANG/google.golang.org/genproto@abcdedf#valid/%2F/subpath");
236+
assertEquals("valid/subpath", purl.getSubpath());
239237
}
240238

241239

@@ -356,6 +354,19 @@ void standardTypes() {
356354
assertEquals("pub", PackageURL.StandardTypes.PUB);
357355
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
358356
assertEquals("rpm", PackageURL.StandardTypes.RPM);
357+
assertEquals("hackage", PackageURL.StandardTypes.HACKAGE);
358+
assertEquals("hex", PackageURL.StandardTypes.HEX);
359+
assertEquals("huggingface", PackageURL.StandardTypes.HUGGINGFACE);
360+
assertEquals("luarocks", PackageURL.StandardTypes.LUAROCKS);
361+
assertEquals("maven", PackageURL.StandardTypes.MAVEN);
362+
assertEquals("mlflow", PackageURL.StandardTypes.MLFLOW);
363+
assertEquals("npm", PackageURL.StandardTypes.NPM);
364+
assertEquals("nuget", PackageURL.StandardTypes.NUGET);
365+
assertEquals("qpkg", PackageURL.StandardTypes.QPKG);
366+
assertEquals("oci", PackageURL.StandardTypes.OCI);
367+
assertEquals("pub", PackageURL.StandardTypes.PUB);
368+
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
369+
assertEquals("rpm", PackageURL.StandardTypes.RPM);
359370
assertEquals("swid", PackageURL.StandardTypes.SWID);
360371
assertEquals("swift", PackageURL.StandardTypes.SWIFT);
361372
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[
2+
{
3+
"description": "a namespace is required",
4+
"purl": "pkg:maven/[email protected]",
5+
"canonical_purl": "pkg:maven/[email protected]",
6+
"type": "maven",
7+
"namespace": null,
8+
"name": null,
9+
"version": null,
10+
"qualifiers": null,
11+
"subpath": null,
12+
"is_invalid": true
13+
},
14+
{
15+
"description": "a namespace is required",
16+
"purl": "pkg:maven//[email protected]",
17+
"canonical_purl": "pkg:maven//[email protected]",
18+
"type": "maven",
19+
"namespace": null,
20+
"name": null,
21+
"version": null,
22+
"qualifiers": null,
23+
"subpath": null,
24+
"is_invalid": true
25+
},
26+
{
27+
"description": "valid debian purl containing a plus in the name and version",
28+
"purl": "pkg:deb/debian/[email protected]+6",
29+
"canonical_purl": "pkg:deb/debian/g%2B%[email protected]%2B6",
30+
"type": "deb",
31+
"namespace": "debian",
32+
"name": "g++-10",
33+
"version": "10.2.1+6",
34+
"qualifiers": null,
35+
"subpath": null,
36+
"is_invalid": false
37+
},
38+
{
39+
"description": "Maven Central is too permissive",
40+
"purl": "pkg:maven/net.databinder/dispatch-http%[email protected]",
41+
"canonical_purl": "pkg:maven/net.databinder/dispatch-http%[email protected]",
42+
"type": "maven",
43+
"namespace": "net.databinder",
44+
"name": "dispatch-http%2Bjson_2.7.3",
45+
"version": "0.6.0",
46+
"is_invalid": false
47+
},
48+
{
49+
"description": "PURLs are ASCII",
50+
"purl": "pkg:nuget/史密斯图wpf控件@1.0.3",
51+
"canonical_purl": "pkg:nuget/%E5%8F%B2%E5%AF%86%E6%96%AF%E5%9B%BEwpf%E6%8E%A7%E4%BB%[email protected]",
52+
"type": "nuget",
53+
"name": "\u53f2\u5bc6\u65af\u56fewpf\u63a7\u4ef6",
54+
"version": "1.0.3",
55+
"is_invalid": false
56+
}
57+
]

0 commit comments

Comments
 (0)