Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@
<json.version>20250107</json.version>
<junit-bom.version>5.12.1</junit-bom.version>
<maven-surefire-junit5-tree-reporter.version>1.4.0</maven-surefire-junit5-tree-reporter.version>
<biz.aQute.bnd.annotation.version>7.1.0</biz.aQute.bnd.annotation.version>
<jspecify.version>1.0.0</jspecify.version>
<org.osgi.annotation.bundle.version>2.0.0</org.osgi.annotation.bundle.version>
</properties>

<dependencyManagement>
Expand All @@ -158,6 +161,12 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>biz.aQute.bnd</groupId>
<artifactId>biz.aQute.bnd.annotation</artifactId>
<version>${biz.aQute.bnd.annotation.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
Expand All @@ -168,13 +177,13 @@
<dependency>
<groupId>org.osgi</groupId>
<artifactId>org.osgi.annotation.bundle</artifactId>
<version>2.0.0</version>
<version>${org.osgi.annotation.bundle.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
<version>${jspecify.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
Expand Down
44 changes: 33 additions & 11 deletions src/main/java/com/github/packageurl/PackageURL.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import static java.util.Objects.requireNonNull;

import com.github.packageurl.internal.PackageTypeFactory;
import com.github.packageurl.internal.StringUtil;
import java.io.Serializable;
import java.net.URI;
Expand Down Expand Up @@ -73,34 +74,34 @@ public final class PackageURL implements Serializable {
private final String type;

/**
* The name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization.
* The name prefix such as a Maven groupId, a Docker image owner, a GitHub user or organization.
* Optional and type-specific.
*/
private final @Nullable String namespace;
private @Nullable String namespace;

/**
* The name of the package.
* Required.
*/
private final String name;
private String name;

/**
* The version of the package.
* Optional.
*/
private final @Nullable String version;
private @Nullable String version;

/**
* Extra qualifying data for a package such as an OS, architecture, a distro, etc.
* Optional and type-specific.
*/
private final @Nullable Map<String, String> qualifiers;
private @Nullable Map<String, String> qualifiers;

/**
* Extra subpath within a package, relative to the package root.
* Optional.
*/
private final @Nullable String subpath;
private @Nullable String subpath;

/**
* Constructs a new PackageURL object by parsing the specified string.
Expand Down Expand Up @@ -190,7 +191,6 @@ 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);
} catch (URISyntaxException e) {
throw new MalformedPackageURLException("Invalid purl: " + e.getMessage(), e);
}
Expand Down Expand Up @@ -235,7 +235,6 @@ public PackageURL(
this.version = validateVersion(this.type, version);
this.qualifiers = parseQualifiers(qualifiers);
this.subpath = validateSubpath(subpath);
verifyTypeConstraints(this.type, this.namespace, this.name);
}

/**
Expand Down Expand Up @@ -501,6 +500,18 @@ private static void validateValue(final String key, final @Nullable String value
}
}

/**
* Returns a new Package URL which is normalized.
*
* @return the normalized package URL
* @throws MalformedPackageURLException if an error occurs while normalizing this package URL
*/
public PackageURL normalize() throws MalformedPackageURLException {
PackageTypeFactory.getInstance().validateComponents(type, namespace, name, version, qualifiers, subpath);
return PackageTypeFactory.getInstance()
.normalizeComponents(type, namespace, name, version, qualifiers, subpath);
}

/**
* Returns the canonicalized representation of the purl.
*
Expand Down Expand Up @@ -528,6 +539,17 @@ public String canonicalize() {
* @since 1.3.2
*/
private String canonicalize(boolean coordinatesOnly) {
try {
PackageURL packageURL = normalize();
namespace = packageURL.getNamespace();
name = packageURL.getName();
version = packageURL.getVersion();
qualifiers = packageURL.getQualifiers();
subpath = packageURL.getSubpath();
} catch (MalformedPackageURLException e) {
throw new ValidationException("Normalization failed", e);
}

Comment on lines +542 to +552
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of a hack right now.

final StringBuilder purl = new StringBuilder();
purl.append(SCHEME_PART).append(type).append('/');
if (namespace != null) {
Expand All @@ -540,7 +562,7 @@ private String canonicalize(boolean coordinatesOnly) {
}

if (!coordinatesOnly) {
if (qualifiers != null) {
if (!qualifiers.isEmpty()) {
purl.append('?');
Set<Map.Entry<String, String>> entries = qualifiers.entrySet();
boolean separator = false;
Expand Down Expand Up @@ -898,15 +920,15 @@ public static final class StandardTypes {
* @deprecated use {@link #DEB} instead
*/
@Deprecated
public static final String DEBIAN = "deb";
public static final String DEBIAN = DEB;
/**
* Nixos packages.
*
* @since 1.1.0
* @deprecated use {@link #NIX} instead
*/
@Deprecated
public static final String NIXPKGS = "nix";
public static final String NIXPKGS = NIX;

private StandardTypes() {}
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/github/packageurl/ValidationException.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ public class ValidationException extends RuntimeException {
public ValidationException(String msg) {
super(msg);
}

ValidationException(String msg, Throwable cause) {
super(msg, cause);
}
}
216 changes: 216 additions & 0 deletions src/main/java/com/github/packageurl/internal/PackageTypeFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/*
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.github.packageurl.internal;

import aQute.bnd.annotation.spi.ServiceConsumer;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.spi.PackageTypeProvider;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.TreeMap;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.osgi.annotation.bundle.Requirement;

@ServiceConsumer(
value = PackageTypeProvider.class,
resolution = Requirement.Resolution.MANDATORY,
cardinality = Requirement.Cardinality.MULTIPLE)
public final class PackageTypeFactory implements PackageTypeProvider {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It bothers me to have the factory class also implementing some type of provider, since it's not actually added to the set of providers.

This is trying to provider the default normalization that needs to happen before all type-specific normalization.

But, maybe there is a better way to do this (add it to the list in the first position?).

private static final @NonNull PackageTypeFactory INSTANCE = new PackageTypeFactory();

public static final @NonNull String TYPE = "__packagetypefactory__";

private @Nullable Map<@NonNull String, @NonNull PackageTypeProvider> packageTypeProviders;

private PackageTypeFactory() {}

public static @NonNull PackageTypeFactory getInstance() {
return INSTANCE;
}

private static @NonNull String normalizeType(@NonNull String type) {
return StringUtil.toLowerCase(type);
}

private static @Nullable String normalizeSubpath(@Nullable String subpath) {
if (subpath == null) {
return null;
}

String[] segments = subpath.split("/", -1);
List<String> segmentList = new ArrayList<>(segments.length);

for (String segment : segments) {
if (!"..".equals(segment) && !".".equals(segment)) {
segmentList.add(segment);
}
}

return String.join("/", segmentList);
}

private static @Nullable Map<String, String> normalizeQualifiers(@Nullable Map<String, String> qualifiers)
throws MalformedPackageURLException {
if (qualifiers == null) {
return null;
}

Set<Map.Entry<String, String>> entries = qualifiers.entrySet();
Map<String, String> map = new TreeMap<>();

for (Map.Entry<String, String> entry : entries) {
String key = StringUtil.toLowerCase(entry.getKey());

if (map.put(key, entry.getValue()) != null) {
throw new MalformedPackageURLException("duplicate qualifiers key '" + key + "'");
}
}

return Collections.unmodifiableMap(map);
}

private static void validateQualifiers(@Nullable Map<String, String> qualifiers)
throws MalformedPackageURLException {
if (qualifiers == null || qualifiers.isEmpty()) {
return;
}

Set<Map.Entry<String, String>> entries = qualifiers.entrySet();

for (Map.Entry<String, String> entry : entries) {
String key = entry.getKey();

if (!key.chars().allMatch(StringUtil::isValidCharForKey)) {
throw new MalformedPackageURLException("checks for invalid qualifier keys. The qualifier key '" + key
+ "' contains invalid characters");
}
}
}

public static void validateType(@NonNull String type) throws MalformedPackageURLException {
if (type.isEmpty()) {
throw new MalformedPackageURLException("a type is always required");
}

char first = type.charAt(0);

if (!StringUtil.isAlpha(first)) {
throw new MalformedPackageURLException("check for type that starts with number: '" + first + "'");
}

Map<Integer, Character> map = new LinkedHashMap<>(type.length());
type.chars().filter(c -> !StringUtil.isValidCharForType(c)).forEach(c -> map.put(c, (char) c));

if (!map.isEmpty()) {
throw new MalformedPackageURLException("check for invalid characters in type: " + map);
}
}

static void validateName(@NonNull String name) throws MalformedPackageURLException {
if (name.isEmpty()) {
throw new MalformedPackageURLException("a name is always required");
}
}

@Override
public void validateComponents(
@NonNull String type,
@Nullable String namespace,
@NonNull String name,
@Nullable String version,
@Nullable Map<String, String> qualifiers,
@Nullable String subpath)
throws MalformedPackageURLException {
validateType(type);
validateName(name);
validateQualifiers(qualifiers);

String normalizedType = normalizeType(type);
Map<String, String> normalizedQualifiers = normalizeQualifiers(qualifiers);
String normalizedSubpath = normalizeSubpath(subpath);
PackageTypeProvider archiveStreamProvider = getPackageTypeProviders().get(normalizedType);

if (archiveStreamProvider != null) {
archiveStreamProvider.validateComponents(
normalizedType, namespace, name, version, normalizedQualifiers, normalizedSubpath);
}
}

@Override
public @NonNull PackageURL normalizeComponents(
@NonNull String type,
@Nullable String namespace,
@NonNull String name,
@Nullable String version,
@Nullable Map<String, String> qualifiers,
@Nullable String subpath)
throws MalformedPackageURLException {
String normalizedType = normalizeType(type);
Map<String, String> normalizedQualifiers = normalizeQualifiers(qualifiers);
String normalizedSubpath = normalizeSubpath(subpath);
PackageTypeProvider archiveStreamProvider = getPackageTypeProviders().get(normalizedType);

if (archiveStreamProvider != null) {
return archiveStreamProvider.normalizeComponents(
normalizedType, namespace, name, version, normalizedQualifiers, normalizedSubpath);
}

return new PackageURL(normalizedType, namespace, name, version, normalizedQualifiers, normalizedSubpath);
}

@Override
public @NonNull String getPackageType() {
return TYPE;
}

@SuppressWarnings("removal")
private static @NonNull Map<@NonNull String, @NonNull PackageTypeProvider> findAvailablePackageTypeProviders() {
return AccessController.doPrivileged((PrivilegedAction<Map<String, PackageTypeProvider>>) () -> {
Map<String, PackageTypeProvider> map = new TreeMap<>();
ServiceLoader<PackageTypeProvider> loader =
ServiceLoader.load(PackageTypeProvider.class, ClassLoader.getSystemClassLoader());

for (PackageTypeProvider provider : loader) {
map.put(provider.getPackageType(), provider);
}

return Collections.unmodifiableMap(map);
});
}

public @NonNull Map<String, PackageTypeProvider> getPackageTypeProviders() {
if (packageTypeProviders == null) {
packageTypeProviders = findAvailablePackageTypeProviders();
}

return Collections.unmodifiableMap(packageTypeProviders);
}
}
Loading
Loading