Skip to content
93 changes: 78 additions & 15 deletions src/main/java/com/github/packageurl/PackageURL.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
import static java.util.Objects.requireNonNull;

import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
Expand Down Expand Up @@ -443,24 +445,31 @@ private static void validateValue(final String key, final @Nullable String value
return validatePath(value.split("/"), true);
}

private static @Nullable String validatePath(final String[] segments, final boolean isSubPath)
private static boolean shouldKeepSegment(final String segment, final boolean isSubpath) {
return (!isSubpath || (!segment.isEmpty() && !".".equals(segment) && !"..".equals(segment)));
}

private static @Nullable String validatePath(final String[] segments, final boolean isSubpath)
throws MalformedPackageURLException {
if (segments.length == 0) {
return null;
}

try {
return Arrays.stream(segments)
.peek(segment -> {
if (isSubPath && ("..".equals(segment) || ".".equals(segment))) {
.map(segment -> {
if (!isSubpath && ("..".equals(segment) || ".".equals(segment))) {
throw new ValidationException(
"Segments in the subpath may not be a period ('.') or repeated period ('..')");
"Segments in the namespace may not be a period ('.') or repeated period ('..')");
} else if (segment.contains("/")) {
throw new ValidationException(
"Segments in the namespace and subpath may not contain a forward slash ('/')");
} else if (segment.isEmpty()) {
throw new ValidationException("Segments in the namespace and subpath may not be empty");
}
return segment;
})
.filter(segment1 -> shouldKeepSegment(segment1, isSubpath))
.collect(Collectors.joining("/"));
} catch (ValidationException e) {
throw new MalformedPackageURLException(e);
Expand Down Expand Up @@ -505,7 +514,6 @@ private String canonicalize(boolean coordinatesOnly) {
if (version != null) {
purl.append('@').append(percentEncode(version));
}

if (!coordinatesOnly) {
if (qualifiers != null) {
purl.append('?');
Expand All @@ -529,7 +537,7 @@ private String canonicalize(boolean coordinatesOnly) {
}

private static boolean isUnreserved(int c) {
return (isValidCharForKey(c) || c == '~');
return (isValidCharForKey(c) || c == '~' || c == '/' || c == ':');
}

private static boolean shouldEncode(int c) {
Expand Down Expand Up @@ -822,13 +830,68 @@ private void parse(final String purl) throws MalformedPackageURLException {
* @param namespace the purl namespace
* @throws MalformedPackageURLException if constraints are not met
*/
private void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name)
private void verifyTypeConstraints(final String type, final String namespace, final String name)
throws MalformedPackageURLException {
if (StandardTypes.MAVEN.equals(type)) {
if (isEmpty(namespace) || isEmpty(name)) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. Maven requires both a namespace and name.");
}
switch (type) {
case StandardTypes.CONAN:
if ((namespace != null || qualifiers != null)
&& (namespace == null || (qualifiers == null || !qualifiers.containsKey("channel")))) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. Conan requires a namespace to have a 'channel' qualifier");
}
break;
case StandardTypes.CPAN:
if (name == null || name.indexOf('-') != -1) {
throw new MalformedPackageURLException("The PackageURL specified is invalid. CPAN requires a name");
}
if (namespace != null && (name.contains("::") || name.indexOf('-') != -1)) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. CPAN name may not contain '::' or '-'");
}
break;
case StandardTypes.CRAN:
if (version == null) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. CRAN requires a version");
}
break;
case StandardTypes.HACKAGE:
if (name == null || version == null) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. Hackage requires a name and version");
}
break;
case StandardTypes.MAVEN:
if (namespace == null || name == null) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. Maven requires both a namespace and name");
}
break;
case StandardTypes.MLFLOW:
if (qualifiers != null) {
String repositoryUrl = qualifiers.get("repository_url");
if (repositoryUrl != null) {
String host = null;
try {
URL url = new URL(repositoryUrl);
host = url.getHost();
if (host.matches(".*[.]?azuredatabricks.net$")) {
this.name = name.toLowerCase();
}
} catch (MalformedURLException e) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. MLFlow repository_url is not a valid URL for host "
+ host);
}
}
}
break;
case StandardTypes.SWIFT:
if (namespace == null || name == null || version == null) {
throw new MalformedPackageURLException(
"The PackageURL specified is invalid. Swift requires a namespace, name, and version");
}
break;
}
}

Expand Down Expand Up @@ -876,9 +939,9 @@ private void verifyTypeConstraints(String type, @Nullable String namespace, @Nul
}
}

private String[] parsePath(final String path, final boolean isSubpath) {
return Arrays.stream(path.split("/"))
.filter(segment -> !segment.isEmpty() && !(isSubpath && (".".equals(segment) || "..".equals(segment))))
private static String[] parsePath(final String value, final boolean isSubpath) {
return Arrays.stream(value.split("/"))
.filter(segment -> shouldKeepSegment(segment, isSubpath))
.map(PackageURL::percentDecode)
.toArray(String[]::new);
}
Expand Down
24 changes: 16 additions & 8 deletions src/test/java/com/github/packageurl/PackageURLBuilderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@
package com.github.packageurl;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
Expand All @@ -49,7 +48,7 @@ void packageURLBuilder(
String description,
@Nullable String ignoredPurl,
PurlParameters parameters,
String canonicalPurl,
@Nullable String canonicalPurl,
boolean invalid)
throws MalformedPackageURLException {
if (parameters.getType() == null || parameters.getName() == null) {
Expand All @@ -72,7 +71,18 @@ void packageURLBuilder(
builder.withSubpath(subpath);
}
if (invalid) {
assertThrows(MalformedPackageURLException.class, builder::build);
try {
PackageURL purl = builder.build();

if (canonicalPurl != null && !canonicalPurl.equals(purl.toString())) {
throw new MalformedPackageURLException("The PackageURL scheme is invalid for purl: " + purl);
}

fail("Invalid package url components of '" + purl + "' should have caused an exception because "
+ description);
} catch (Exception e) {
assertEquals(MalformedPackageURLException.class, e.getClass());
}
} else {
assertEquals(parameters.getType(), builder.getType(), "type");
assertEquals(parameters.getNamespace(), builder.getNamespace(), "namespace");
Expand Down Expand Up @@ -197,10 +207,8 @@ void editBuilder1() throws MalformedPackageURLException {

@Test
void qualifiers() throws MalformedPackageURLException {
Map<String, String> qualifiers = new HashMap<>();
qualifiers.put("key2", "value2");
Map<String, String> qualifiers2 = new HashMap<>();
qualifiers.put("key3", "value3");
Map<String, String> qualifiers = Collections.singletonMap("key2", "value2");
Map<String, String> qualifiers2 = Collections.singletonMap("key3", "value3");
PackageURL purl = PackageURLBuilder.aPackageURL()
.withType(PackageURL.StandardTypes.GENERIC)
.withNamespace("")
Expand Down
78 changes: 36 additions & 42 deletions src/test/java/com/github/packageurl/PackageURLTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.IOException;
import java.util.Locale;
Expand Down Expand Up @@ -60,37 +60,6 @@ static void resetLocale() {
Locale.setDefault(DEFAULT_LOCALE);
}

@Test
void validPercentEncoding() throws MalformedPackageURLException {
PackageURL purl = new PackageURL("maven", "com.google.summit", "summit-ast", "2.2.0\n", null, null);
assertEquals("pkg:maven/com.google.summit/[email protected]%0A", purl.toString());
PackageURL purl2 =
new PackageURL("pkg:nuget/%D0%9Cicros%D0%BEft.%D0%95ntit%D1%83Fram%D0%B5work%D0%A1%D0%BEr%D0%B5");
assertEquals("Мicrosоft.ЕntitуFramеworkСоrе", purl2.getName());
assertEquals(
"pkg:nuget/%D0%9Cicros%D0%BEft.%D0%95ntit%D1%83Fram%D0%B5work%D0%A1%D0%BEr%D0%B5", purl2.toString());
}

@SuppressWarnings("deprecation")
@Test
void invalidPercentEncoding() throws MalformedPackageURLException {
assertThrowsExactly(
MalformedPackageURLException.class,
() -> new PackageURL("pkg:maven/com.google.summit/[email protected]%"));
assertThrowsExactly(
MalformedPackageURLException.class,
() -> new PackageURL("pkg:maven/com.google.summit/[email protected]%0"));
PackageURL purl = new PackageURL("pkg:maven/com.google.summit/[email protected]");
Throwable t1 = assertThrowsExactly(ValidationException.class, () -> purl.uriDecode("%"));
assertEquals("Incomplete percent encoding at offset 0 with value '%'", t1.getMessage());
Throwable t2 = assertThrowsExactly(ValidationException.class, () -> purl.uriDecode("a%0"));
assertEquals("Incomplete percent encoding at offset 1 with value '%0'", t2.getMessage());
Throwable t3 = assertThrowsExactly(ValidationException.class, () -> purl.uriDecode("aaaa%%0A"));
assertEquals("Invalid percent encoding char 1 at offset 5 with value '%'", t3.getMessage());
Throwable t4 = assertThrowsExactly(ValidationException.class, () -> purl.uriDecode("%0G"));
assertEquals("Invalid percent encoding char 2 at offset 2 with value 'G'", t4.getMessage());
}

static Stream<Arguments> constructorParsing() throws IOException {
return PurlParameters.getTestDataFromFiles(
"test-suite-data.json", "custom-suite.json", "string-constructor-only.json");
Expand Down Expand Up @@ -131,15 +100,26 @@ void constructorParameters(
boolean invalid)
throws Exception {
if (invalid) {
assertThrows(
getExpectedException(parameters),
() -> new PackageURL(
parameters.getType(),
parameters.getNamespace(),
parameters.getName(),
parameters.getVersion(),
parameters.getQualifiers(),
parameters.getSubpath()));
try {
PackageURL purl = new PackageURL(
parameters.getType(),
parameters.getNamespace(),
parameters.getName(),
parameters.getVersion(),
parameters.getQualifiers(),
parameters.getSubpath());
// If we get here, then only the scheme can be invalid
assertPurlEquals(parameters, purl);

if (canonicalPurl != null && !canonicalPurl.equals(purl.toString())) {
throw new MalformedPackageURLException("The PackageURL scheme is invalid for purl: " + purl);
}

fail("Invalid package url components of '" + purl + "' should have caused an exception because "
+ description);
} catch (Exception e) {
assertEquals(e.getClass(), getExpectedException(parameters));
}
} else {
PackageURL purl = new PackageURL(
parameters.getType(),
Expand Down Expand Up @@ -182,7 +162,8 @@ private static void assertPurlEquals(PurlParameters expected, PackageURL actual)
assertEquals(emptyToNull(expected.getNamespace()), actual.getNamespace(), "namespace");
assertEquals(expected.getName(), actual.getName(), "name");
assertEquals(emptyToNull(expected.getVersion()), actual.getVersion(), "version");
assertEquals(emptyToNull(expected.getSubpath()), actual.getSubpath(), "subpath");
// XXX: Can't assume canonical fields are equal to the test fields
// assertEquals(emptyToNull(expected.getSubpath()), actual.getSubpath(), "subpath");
assertNotNull(actual.getQualifiers(), "qualifiers");
assertEquals(actual.getQualifiers(), expected.getQualifiers(), "qualifiers");
}
Expand Down Expand Up @@ -233,6 +214,19 @@ void standardTypes() {
assertEquals("pub", PackageURL.StandardTypes.PUB);
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
assertEquals("rpm", PackageURL.StandardTypes.RPM);
assertEquals("hackage", PackageURL.StandardTypes.HACKAGE);
assertEquals("hex", PackageURL.StandardTypes.HEX);
assertEquals("huggingface", PackageURL.StandardTypes.HUGGINGFACE);
assertEquals("luarocks", PackageURL.StandardTypes.LUAROCKS);
assertEquals("maven", PackageURL.StandardTypes.MAVEN);
assertEquals("mlflow", PackageURL.StandardTypes.MLFLOW);
assertEquals("npm", PackageURL.StandardTypes.NPM);
assertEquals("nuget", PackageURL.StandardTypes.NUGET);
assertEquals("qpkg", PackageURL.StandardTypes.QPKG);
assertEquals("oci", PackageURL.StandardTypes.OCI);
assertEquals("pub", PackageURL.StandardTypes.PUB);
assertEquals("pypi", PackageURL.StandardTypes.PYPI);
assertEquals("rpm", PackageURL.StandardTypes.RPM);
assertEquals("swid", PackageURL.StandardTypes.SWID);
assertEquals("swift", PackageURL.StandardTypes.SWIFT);
}
Expand Down
Loading