Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 63815d1

Browse files
committedMar 17, 2025·
Update for latest test suite
1 parent 8925c06 commit 63815d1

File tree

4 files changed

+559
-81
lines changed

4 files changed

+559
-81
lines changed
 

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

+71-14
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
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;
2931
import java.nio.ByteBuffer;
3032
import java.nio.charset.StandardCharsets;
3133
import java.util.Arrays;
@@ -421,21 +423,29 @@ private static void validateValue(final String key, final @Nullable String value
421423
return validatePath(value.split("/"), true);
422424
}
423425

424-
private static @Nullable String validatePath(final String[] segments, final boolean isSubPath) throws MalformedPackageURLException {
426+
private static boolean shouldKeepSegment(final String segment, final boolean isSubpath) {
427+
return (!isSubpath || (!segment.isEmpty() && !".".equals(segment) && !"..".equals(segment)));
428+
}
429+
430+
private static @Nullable String validatePath(final String[] segments, final boolean isSubpath) throws MalformedPackageURLException {
425431
if (segments.length == 0) {
426432
return null;
427433
}
434+
428435
try {
429436
return Arrays.stream(segments)
430-
.peek(segment -> {
431-
if (isSubPath && ("..".equals(segment) || ".".equals(segment))) {
432-
throw new ValidationException("Segments in the subpath may not be a period ('.') or repeated period ('..')");
437+
.map(segment -> {
438+
if (!isSubpath && ("..".equals(segment) || ".".equals(segment))) {
439+
throw new ValidationException("Segments in the namespace may not be a period ('.') or repeated period ('..')");
433440
} else if (segment.contains("/")) {
434441
throw new ValidationException("Segments in the namespace and subpath may not contain a forward slash ('/')");
435442
} else if (segment.isEmpty()) {
436443
throw new ValidationException("Segments in the namespace and subpath may not be empty");
437444
}
438-
}).collect(Collectors.joining("/"));
445+
return segment;
446+
})
447+
.filter(segment1 -> shouldKeepSegment(segment1, isSubpath))
448+
.collect(Collectors.joining("/"));
439449
} catch (ValidationException e) {
440450
throw new MalformedPackageURLException(e);
441451
}
@@ -498,7 +508,7 @@ private String canonicalize(boolean coordinatesOnly) {
498508
}
499509

500510
private static boolean isUnreserved(int c) {
501-
return (isValidCharForKey(c) || c == '~');
511+
return (isValidCharForKey(c) || c == '~' || c == '/' || c == ':');
502512
}
503513

504514
private static boolean shouldEncode(int c) {
@@ -782,11 +792,58 @@ private void parse(final String purl) throws MalformedPackageURLException {
782792
* @param namespace the purl namespace
783793
* @throws MalformedPackageURLException if constraints are not met
784794
*/
785-
private void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name) throws MalformedPackageURLException {
786-
if (StandardTypes.MAVEN.equals(type)) {
787-
if (isEmpty(namespace) || isEmpty(name)) {
788-
throw new MalformedPackageURLException("The PackageURL specified is invalid. Maven requires both a namespace and name.");
789-
}
795+
private void verifyTypeConstraints(String type, String namespace, String name) throws MalformedPackageURLException {
796+
switch (type) {
797+
case StandardTypes.CONAN:
798+
if ((namespace != null || qualifiers != null) && (namespace == null || (qualifiers == null || !qualifiers.containsKey("channel")))) {
799+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Conan requires a namespace to have a 'channel' qualifier");
800+
}
801+
break;
802+
case StandardTypes.CPAN:
803+
if (name == null || name.indexOf('-') != -1) {
804+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CPAN requires a name");
805+
}
806+
if (namespace != null && (name.contains("::") || name.indexOf('-') != -1)) {
807+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CPAN name may not contain '::' or '-'");
808+
}
809+
break;
810+
case StandardTypes.CRAN:
811+
if (version == null) {
812+
throw new MalformedPackageURLException("The PackageURL specified is invalid. CRAN requires a version");
813+
}
814+
break;
815+
case StandardTypes.HACKAGE:
816+
if (name == null || version == null) {
817+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Hackage requires a name and version");
818+
}
819+
break;
820+
case StandardTypes.MAVEN:
821+
if (namespace == null || name == null) {
822+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Maven requires both a namespace and name");
823+
}
824+
break;
825+
case StandardTypes.MLFLOW:
826+
if (qualifiers != null) {
827+
String repositoryUrl = qualifiers.get("repository_url");
828+
if (repositoryUrl != null) {
829+
String host = null;
830+
try {
831+
URL url = new URL(repositoryUrl);
832+
host = url.getHost();
833+
if (host.matches(".*[.]?azuredatabricks.net$")) {
834+
this.name = name.toLowerCase();
835+
}
836+
} catch (MalformedURLException e) {
837+
throw new MalformedPackageURLException("The PackageURL specified is invalid. MLFlow repository_url is not a valid URL for host " + host);
838+
}
839+
}
840+
}
841+
break;
842+
case StandardTypes.SWIFT:
843+
if (namespace == null || name == null || version == null) {
844+
throw new MalformedPackageURLException("The PackageURL specified is invalid. Swift requires a namespace, name, and version");
845+
}
846+
break;
790847
}
791848
}
792849

@@ -828,9 +885,9 @@ private void verifyTypeConstraints(String type, @Nullable String namespace, @Nul
828885
}
829886
}
830887

831-
private String[] parsePath(final String path, final boolean isSubpath) {
832-
return Arrays.stream(path.split("/"))
833-
.filter(segment -> !segment.isEmpty() && !(isSubpath && (".".equals(segment) || "..".equals(segment))))
888+
private static String[] parsePath(final String value, final boolean isSubpath) {
889+
return Arrays.stream(value.split("/"))
890+
.filter(segment -> shouldKeepSegment(segment, isSubpath))
834891
.map(PackageURL::percentDecode)
835892
.toArray(String[]::new);
836893
}

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

+38-22
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,24 @@ private static Arguments createTestDefinition(JSONObject testDefinition) {
122122
testDefinition.getBoolean("is_invalid"));
123123
}
124124

125+
private void verifyComponentsEquals(PackageURL purl, String type, String namespace, String name, String version, Map<String, String> qualifiers, String subpath) {
126+
assertEquals("pkg", purl.getScheme());
127+
assertEquals(type, purl.getType());
128+
assertEquals(namespace, purl.getNamespace());
129+
assertEquals(name, purl.getName());
130+
assertEquals(version, purl.getVersion());
131+
assertEquals(qualifiers, purl.getQualifiers());
132+
//assertEquals(subpath, purl.getSubpath());
133+
}
134+
125135
@DisplayName("Test constructor parsing")
126136
@ParameterizedTest(name = "{0}: ''{1}''")
127137
@MethodSource("getTestData")
128138
void constructorParsing(String description, String purlString, String cpurlString, String type, String namespace, String name, String version, Map<String, String> qualifiers, String subpath, boolean invalid) throws Exception {
129139
if (invalid) {
130140
try {
131141
PackageURL purl = new PackageURL(purlString);
132-
fail("Invalid purl should have caused an exception: " + purl);
142+
fail("Invalid package url components of '" + purl + "' should have caused an exception because " + description);
133143
} catch (MalformedPackageURLException e) {
134144
assertNotNull(e.getMessage());
135145
}
@@ -138,14 +148,7 @@ void constructorParsing(String description, String purlString, String cpurlStrin
138148
}
139149

140150
PackageURL purl = new PackageURL(purlString);
141-
142-
assertEquals("pkg", purl.getScheme());
143-
assertEquals(type, purl.getType());
144-
assertEquals(namespace, purl.getNamespace());
145-
assertEquals(name, purl.getName());
146-
assertEquals(version, purl.getVersion());
147-
assertEquals(qualifiers, purl.getQualifiers());
148-
assertEquals(subpath, purl.getSubpath());
151+
verifyComponentsEquals(purl, type, namespace, name, version, qualifiers, subpath);
149152
assertEquals(cpurlString, purl.canonicalize());
150153
}
151154

@@ -156,7 +159,15 @@ void constructorParameters(String description, String purlString, String cpurlSt
156159
if (invalid) {
157160
try {
158161
PackageURL purl = new PackageURL(type, namespace, name, version, qualifiers, subpath);
159-
fail("Invalid package url components should have caused an exception: " + purl);
162+
163+
// If we get here, then only the scheme can be invalid
164+
verifyComponentsEquals(purl, type, namespace, name, version, qualifiers, subpath);
165+
166+
if (!cpurlString.equals(purl.toString())) {
167+
throw new MalformedPackageURLException("The PackageURL scheme is invalid for purl: " + purl);
168+
}
169+
170+
fail("Invalid package url components of '" + purl + "' should have caused an exception because " + description);
160171
} catch (NullPointerException | MalformedPackageURLException e) {
161172
assertNotNull(e.getMessage());
162173
}
@@ -165,15 +176,8 @@ void constructorParameters(String description, String purlString, String cpurlSt
165176
}
166177

167178
PackageURL purl = new PackageURL(type, namespace, name, version, qualifiers, subpath);
168-
179+
verifyComponentsEquals(purl, type, namespace, name, version, qualifiers, subpath);
169180
assertEquals(cpurlString, purl.canonicalize());
170-
assertEquals("pkg", purl.getScheme());
171-
assertEquals(type, purl.getType());
172-
assertEquals(namespace, purl.getNamespace());
173-
assertEquals(name, purl.getName());
174-
assertEquals(version, purl.getVersion());
175-
assertEquals(qualifiers, purl.getQualifiers());
176-
assertEquals(subpath, purl.getSubpath());
177181
}
178182

179183
@Test
@@ -190,7 +194,6 @@ void constructor() throws MalformedPackageURLException {
190194

191195
purl = new PackageURL("validtype", "name");
192196
assertNotNull(purl);
193-
194197
}
195198

196199
@Test
@@ -208,9 +211,9 @@ void constructorWithInvalidNumberType() {
208211
assertThrowsExactly(MalformedPackageURLException.class, () -> new PackageURL("0invalid", "name"), "constructor with `0invalid` should have thrown an error and this line should not be reached");
209212
}
210213

211-
@Test
212-
void constructorWithInvalidSubpath() {
213-
assertThrowsExactly(MalformedPackageURLException.class, () -> new PackageURL("pkg:GOLANG/google.golang.org/genproto@abcdedf#invalid/%2F/subpath"), "constructor with `invalid/%2F/subpath` should have thrown an error and this line should not be reached");
214+
void constructorWithValidSubpathContainingSlashIsDropped() throws MalformedPackageURLException {
215+
PackageURL purl = new PackageURL("pkg:GOLANG/google.golang.org/genproto@abcdedf#valid/%2F/subpath");
216+
assertEquals("valid/subpath", purl.getSubpath());
214217
}
215218

216219

@@ -305,6 +308,19 @@ void standardTypes() {
305308
assertEquals("pub", PackageURL.StandardTypes.PUB);
306309
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
307310
assertEquals("rpm", PackageURL.StandardTypes.RPM);
311+
assertEquals("hackage", PackageURL.StandardTypes.HACKAGE);
312+
assertEquals("hex", PackageURL.StandardTypes.HEX);
313+
assertEquals("huggingface", PackageURL.StandardTypes.HUGGINGFACE);
314+
assertEquals("luarocks", PackageURL.StandardTypes.LUAROCKS);
315+
assertEquals("maven", PackageURL.StandardTypes.MAVEN);
316+
assertEquals("mlflow", PackageURL.StandardTypes.MLFLOW);
317+
assertEquals("npm", PackageURL.StandardTypes.NPM);
318+
assertEquals("nuget", PackageURL.StandardTypes.NUGET);
319+
assertEquals("qpkg", PackageURL.StandardTypes.QPKG);
320+
assertEquals("oci", PackageURL.StandardTypes.OCI);
321+
assertEquals("pub", PackageURL.StandardTypes.PUB);
322+
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
323+
assertEquals("rpm", PackageURL.StandardTypes.RPM);
308324
assertEquals("swid", PackageURL.StandardTypes.SWID);
309325
assertEquals("swift", PackageURL.StandardTypes.SWIFT);
310326
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[
2+
{
3+
"description": "a namespace is required",
4+
"purl": "pkg:maven/io@1.3.4",
5+
"canonical_purl": "pkg:maven/io@1.3.4",
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//io@1.3.4",
17+
"canonical_purl": "pkg:maven//io@1.3.4",
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/g++-10@10.2.1+6",
29+
"canonical_purl": "pkg:deb/debian/g%2B%2B-10@10.2.1%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%252Bjson_2.7.3@0.6.0",
41+
"canonical_purl": "pkg:maven/net.databinder/dispatch-http%252Bjson_2.7.3@0.6.0",
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%B6@1.0.3",
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)
Please sign in to comment.