Skip to content
97 changes: 83 additions & 14 deletions src/main/java/com/github/packageurl/PackageURL.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@

import com.github.packageurl.internal.StringUtil;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
Expand Down Expand Up @@ -82,7 +84,7 @@ public final class PackageURL implements Serializable {
* The name of the package.
* Required.
*/
private final String name;
private String name;

/**
* The version of the package.
Expand Down Expand Up @@ -190,7 +192,7 @@ public PackageURL(final String purl) throws MalformedPackageURLException {
remainder = remainder.substring(0, index);
this.namespace = validateNamespace(this.type, parsePath(remainder.substring(start), false));
}
verifyTypeConstraints(this.type, this.namespace, this.name);
verifyTypeConstraints(this.type, this.namespace, this.name, this.version, this.qualifiers);
} catch (URISyntaxException e) {
throw new MalformedPackageURLException("Invalid purl: " + e.getMessage(), e);
}
Expand Down Expand Up @@ -235,7 +237,7 @@ public PackageURL(
this.version = validateVersion(this.type, version);
this.qualifiers = parseQualifiers(qualifiers);
this.subpath = validateSubpath(subpath);
verifyTypeConstraints(this.type, this.namespace, this.name);
verifyTypeConstraints(this.type, this.namespace, this.name, this.version, this.qualifiers);
}

/**
Expand Down Expand Up @@ -477,24 +479,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 @@ -538,7 +547,6 @@ private String canonicalize(boolean coordinatesOnly) {
if (version != null) {
purl.append('@').append(StringUtil.percentEncode(version));
}

if (!coordinatesOnly) {
if (qualifiers != null) {
purl.append('?');
Expand Down Expand Up @@ -567,13 +575,74 @@ private String canonicalize(boolean coordinatesOnly) {
* @param namespace the purl namespace
* @throws MalformedPackageURLException if constraints are not met
*/
private static void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name)
private void verifyTypeConstraints(
final String type,
final @Nullable String namespace,
final @Nullable String name,
final @Nullable String version,
final @Nullable Map<String, String> qualifiers)
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$")) {
// TODO: Move this eventually
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
2 changes: 2 additions & 0 deletions src/main/java/com/github/packageurl/internal/StringUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public final class StringUtil {
UNRESERVED_CHARS['.'] = true;
UNRESERVED_CHARS['_'] = true;
UNRESERVED_CHARS['~'] = true;
UNRESERVED_CHARS[':'] = true;
UNRESERVED_CHARS['/'] = true;
}

private StringUtil() {
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, "Build should fail due to " + description);
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 @@ -186,10 +196,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
52 changes: 38 additions & 14 deletions src/test/java/com/github/packageurl/PackageURLTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@

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 @@ -97,7 +97,7 @@ void constructorParsing(
boolean invalid)
throws Exception {
if (invalid) {
assertThrows(
assertThrowsExactly(
getExpectedException(purlString),
() -> new PackageURL(purlString),
"Build should fail due to " + description);
Expand All @@ -124,16 +124,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()),
"Build should fail due to " + description);
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 @@ -161,7 +171,7 @@ void constructorTypeNameSpace(
boolean invalid)
throws Exception {
if (invalid) {
assertThrows(
assertThrowsExactly(
getExpectedException(parameters), () -> new PackageURL(parameters.getType(), parameters.getName()));
} else {
PackageURL purl = new PackageURL(parameters.getType(), parameters.getName());
Expand All @@ -176,7 +186,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 @@ -227,6 +238,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