diff --git a/pom.xml b/pom.xml index 3377f2c..cfc8337 100644 --- a/pom.xml +++ b/pom.xml @@ -43,10 +43,11 @@ + steve.springett Steve Springett Steve.Springett@owasp.org OWASP - http://www.owasp.org/ + https://www.owasp.org/ Architect Developer @@ -142,6 +143,9 @@ 20250107 5.12.1 1.4.0 + 7.1.0 + 1.0.0 + 2.0.0 @@ -158,6 +162,12 @@ + + biz.aQute.bnd + biz.aQute.bnd.annotation + ${biz.aQute.bnd.annotation.version} + provided + jakarta.validation jakarta.validation-api @@ -168,13 +178,13 @@ org.osgi org.osgi.annotation.bundle - 2.0.0 + ${org.osgi.annotation.bundle.version} provided org.jspecify jspecify - 1.0.0 + ${jspecify.version} provided true diff --git a/src/main/java/com/github/packageurl/PackageURL.java b/src/main/java/com/github/packageurl/PackageURL.java index a474651..133b353 100644 --- a/src/main/java/com/github/packageurl/PackageURL.java +++ b/src/main/java/com/github/packageurl/PackageURL.java @@ -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; @@ -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 qualifiers; + private @Nullable Map 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. @@ -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); } @@ -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); } /** @@ -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. * @@ -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); + } + final StringBuilder purl = new StringBuilder(); purl.append(SCHEME_PART).append(type).append('/'); if (namespace != null) { @@ -540,7 +562,7 @@ private String canonicalize(boolean coordinatesOnly) { } if (!coordinatesOnly) { - if (qualifiers != null) { + if (!qualifiers.isEmpty()) { purl.append('?'); Set> entries = qualifiers.entrySet(); boolean separator = false; @@ -561,22 +583,6 @@ private String canonicalize(boolean coordinatesOnly) { return purl.toString(); } - /** - * Some purl types may have specific constraints. This method attempts to verify them. - * @param type the purl type - * @param namespace the purl namespace - * @throws MalformedPackageURLException if constraints are not met - */ - private static void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable 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."); - } - } - } - private static @Nullable Map parseQualifiers(final @Nullable Map qualifiers) throws MalformedPackageURLException { if (qualifiers == null || qualifiers.isEmpty()) { @@ -898,7 +904,7 @@ 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. * @@ -906,7 +912,7 @@ public static final class StandardTypes { * @deprecated use {@link #NIX} instead */ @Deprecated - public static final String NIXPKGS = "nix"; + public static final String NIXPKGS = NIX; private StandardTypes() {} } diff --git a/src/main/java/com/github/packageurl/ValidationException.java b/src/main/java/com/github/packageurl/ValidationException.java index ac4997b..c8f1529 100644 --- a/src/main/java/com/github/packageurl/ValidationException.java +++ b/src/main/java/com/github/packageurl/ValidationException.java @@ -38,4 +38,8 @@ public class ValidationException extends RuntimeException { public ValidationException(String msg) { super(msg); } + + ValidationException(String msg, Throwable cause) { + super(msg, cause); + } } diff --git a/src/main/java/com/github/packageurl/internal/PackageTypeFactory.java b/src/main/java/com/github/packageurl/internal/PackageTypeFactory.java new file mode 100644 index 0000000..35a5897 --- /dev/null +++ b/src/main/java/com/github/packageurl/internal/PackageTypeFactory.java @@ -0,0 +1,240 @@ +/* + * 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.Cardinality; +import aQute.bnd.annotation.Resolution; +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; + +/** + * The PackageTypeFactory is a singleton that provides a way to normalize and validate package URLs. + * It uses the ServiceLoader mechanism to load available PackageTypeProvider implementations. + * This class is not intended to be instantiated directly. + */ +@ServiceConsumer( + value = PackageTypeProvider.class, + resolution = Resolution.MANDATORY, + cardinality = Cardinality.MULTIPLE) +public final class PackageTypeFactory implements PackageTypeProvider { + private static final @NonNull PackageTypeFactory INSTANCE = new PackageTypeFactory(); + + private static final @NonNull String TYPE = "__packagetypefactory__"; + + private @Nullable Map<@NonNull String, @NonNull PackageTypeProvider> packageTypeProviders; + + private PackageTypeFactory() {} + + /** + * Returns the singleton instance of PackageTypeFactory. + * + * @return the singleton instance of 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 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 normalizeQualifiers(@Nullable Map qualifiers) + throws MalformedPackageURLException { + if (qualifiers == null) { + return null; + } + + Set> entries = qualifiers.entrySet(); + Map map = new TreeMap<>(); + + for (Map.Entry 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 qualifiers) + throws MalformedPackageURLException { + if (qualifiers == null || qualifiers.isEmpty()) { + return; + } + + Set> entries = qualifiers.entrySet(); + + for (Map.Entry 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"); + } + } + } + + /** + * Validates the type of the package URL. The type must start with an alphabetic character and + * can only contain alphanumeric characters, underscores, and hyphens. + * + * @param type the type of the package URL + * @throws MalformedPackageURLException if the type is invalid + */ + 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 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 qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + validateType(type); + validateName(name); + validateQualifiers(qualifiers); + + String normalizedType = normalizeType(type); + Map 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 qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + String normalizedType = normalizeType(type); + Map 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 map = new TreeMap<>(); + ServiceLoader loader = + ServiceLoader.load(PackageTypeProvider.class, ClassLoader.getSystemClassLoader()); + + for (PackageTypeProvider provider : loader) { + map.put(provider.getPackageType(), provider); + } + + return Collections.unmodifiableMap(map); + }); + } + + /** + * Returns a map of available package type providers. The map is unmodifiable and contains the + * package type as the key and the corresponding PackageTypeProvider as the value. + * + * @return a map of available package type providers + */ + public @NonNull Map getPackageTypeProviders() { + if (packageTypeProviders == null) { + packageTypeProviders = findAvailablePackageTypeProviders(); + } + + return Collections.unmodifiableMap(packageTypeProviders); + } +} diff --git a/src/main/java/com/github/packageurl/internal/PackageTypeProviders.java b/src/main/java/com/github/packageurl/internal/PackageTypeProviders.java new file mode 100644 index 0000000..4fd31ae --- /dev/null +++ b/src/main/java/com/github/packageurl/internal/PackageTypeProviders.java @@ -0,0 +1,485 @@ +/* + * 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.Resolution; +import aQute.bnd.annotation.spi.ServiceProvider; +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import com.github.packageurl.spi.PackageTypeProvider; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * This class provides a set of package type providers for different package types. + * Each provider implements the PackageTypeProvider interface and provides its own + * validation and normalization logic for the components of a package URL. + */ +public final class PackageTypeProviders { + private PackageTypeProviders() {} + + /** + * This class provides a package type provider for the "apk" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Apk extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "bitbucket" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Bitbucket extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "bitnami" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Bitnami extends LowercaseNamespacePackageTypeProvider {} + + /** + * This class provides a package type provider for the "cocoapods" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Cocoapods implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (namespace != null && !namespace.isEmpty()) { + throw new MalformedPackageURLException("invalid cocoapods purl cannot have a namespace"); + } + + if (name.chars().anyMatch(StringUtil::isWhitespace) || name.startsWith(".") || name.contains("+")) { + throw new MalformedPackageURLException("invalid cocoapods purl invalid name"); + } + } + } + + /** + * This class provides a package type provider for the "composer" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Composer extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "conan" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Conan implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + boolean hasChannel = (qualifiers != null && !qualifiers.isEmpty()); + + if ((namespace != null && !namespace.isEmpty()) && !hasChannel) { + throw new MalformedPackageURLException("invalid conan purl only namespace"); + } else if ((namespace == null || namespace.isEmpty()) && hasChannel) { + throw new MalformedPackageURLException("invalid conan purl only channel qualifier"); + } + } + } + + /** + * This class provides a package type provider for the "cpan" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Cpan implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if ((namespace == null || namespace.isEmpty()) && name.indexOf('-') != -1) { + throw new MalformedPackageURLException("cpan module name like distribution name"); + } else if ((namespace != null && !namespace.isEmpty()) && name.contains("::")) { + throw new MalformedPackageURLException("cpan distribution name like module name"); + } + } + } + + /** + * This class provides a package type provider for the "cran" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Cran implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (version == null || version.isEmpty()) { + throw new MalformedPackageURLException("invalid cran purl without version"); + } + } + } + + /** + * This class provides a package type provider for the "deb" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Deb extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "generic" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Generic implements PackageTypeProvider {} + + /** + * This class provides a package type provider for the "github" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Github extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "golang" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Golang extends LowercaseNamespacePackageTypeProvider {} + + /** + * This class provides a package type provider for the "hackage" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Hackage implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (version == null || version.isEmpty()) { + throw new MalformedPackageURLException("name and version are always required"); + } + } + } + + /** + * This class provides a package type provider for the "hex" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Hex extends LowercaseNamespaceAndNameTypeProvider {} + + /** + * This class provides a package type provider for the "huggingface" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Huggingface extends LowercaseVersionPackageTypeProvider {} + + /** + * This class provides a package type provider for the "luarocks" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Luarocks extends LowercaseVersionPackageTypeProvider {} + + /** + * This class provides a package type provider for the "maven" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Maven implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (namespace == null || namespace.isEmpty()) { + throw new MalformedPackageURLException("a namespace is required"); + } + } + } + + /** + * This class provides a package type provider for the "mlflow" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Mlflow implements PackageTypeProvider { + private static @NonNull String normalizeName(@NonNull String name, @Nullable Map qualifiers) + throws MalformedPackageURLException { + if (qualifiers != null) { + String repositoryUrl = qualifiers.get("repository_url"); + + if (repositoryUrl != null) { + String host; + + try { + URI url = new URI(repositoryUrl); + host = url.getHost(); + + if (host.matches(".*[.]?azuredatabricks.net$")) { + return StringUtil.toLowerCase(name); + } + } catch (URISyntaxException e) { + throw new MalformedPackageURLException( + "'" + repositoryUrl + "' is not a valid URL for repository_url", e); + } + } + } + + return name; + } + + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (namespace != null && !namespace.isEmpty()) { + throw new MalformedPackageURLException("a namespace is not allowed for type '" + type + "'"); + } + } + + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL(type, namespace, normalizeName(name, qualifiers), version, qualifiers, subpath); + } + } + + /** + * This class provides a package type provider for the "oci" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Oci extends LowercaseNameAndVersionPackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (namespace != null && !namespace.isEmpty()) { + throw new MalformedPackageURLException("a namespace is not allowed for type '" + type + "'"); + } + } + } + + /** + * This class provides a package type provider for the "pub" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Pub extends LowercaseNamePackageTypeProvider {} + + /** + * This class provides a package type provider for the "pypi" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Pypi implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL( + type, namespace, StringUtil.toLowerCase(name).replace('_', '-'), version, qualifiers, subpath); + } + } + + /** + * This class provides a package type provider for the "qpkg" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Qpkg extends LowercaseNamespacePackageTypeProvider {} + + /** + * This class provides a package type provider for the "rpm" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Rpm extends LowercaseNamespacePackageTypeProvider {} + + /** + * This class provides a package type provider for the "swift" package type. + */ + @ServiceProvider(value = PackageTypeProvider.class, resolution = Resolution.MANDATORY) + public static class Swift implements PackageTypeProvider { + @Override + public void validateComponents( + @NonNull String type, + @Nullable String namespace, + @Nullable String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + if (namespace == null || namespace.isEmpty()) { + throw new MalformedPackageURLException("invalid swift purl without namespace"); + } + + if (version == null || version.isEmpty()) { + throw new MalformedPackageURLException("invalid swift purl without version"); + } + } + } + + /** + * This class provides a common interface for the lowercase name and version package types. + */ + public static class LowercaseNameAndVersionPackageTypeProvider implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL( + type, + namespace, + StringUtil.toLowerCase(name), + version != null ? StringUtil.toLowerCase(version) : null, + qualifiers, + subpath); + } + } + + /** + * This class provides a common interface for the lowercase name package types. + */ + public static class LowercaseNamePackageTypeProvider implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL(type, namespace, StringUtil.toLowerCase(name), version, qualifiers, subpath); + } + } + + /** + * This class provides a common interface for the lowercase namespace and name package types. + */ + public static class LowercaseNamespaceAndNameTypeProvider implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL( + type, + namespace != null ? StringUtil.toLowerCase(namespace) : null, + StringUtil.toLowerCase(name), + version, + qualifiers, + subpath); + } + } + + /** + * This class provides a common interface for the lowercase namespace package types. + */ + public static class LowercaseNamespacePackageTypeProvider implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL( + type, + namespace != null ? StringUtil.toLowerCase(namespace) : null, + name, + version, + qualifiers, + subpath); + } + } + + /** + * This class provides a common interface for the lowercase version package types. + */ + public static class LowercaseVersionPackageTypeProvider implements PackageTypeProvider { + @Override + public @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL( + type, + namespace, + name, + version != null ? StringUtil.toLowerCase(version) : null, + qualifiers, + subpath); + } + } +} diff --git a/src/main/java/com/github/packageurl/internal/StringUtil.java b/src/main/java/com/github/packageurl/internal/StringUtil.java index 5225ce1..e61da2d 100644 --- a/src/main/java/com/github/packageurl/internal/StringUtil.java +++ b/src/main/java/com/github/packageurl/internal/StringUtil.java @@ -25,6 +25,7 @@ import com.github.packageurl.ValidationException; import java.nio.charset.StandardCharsets; +import java.util.BitSet; import org.jspecify.annotations.NonNull; /** @@ -54,6 +55,19 @@ public final class StringUtil { UNRESERVED_CHARS['~'] = true; } + private static final int NBITS = 128; + + private static final BitSet WHITESPACECHAR = new BitSet(NBITS); + + static { + WHITESPACECHAR.set(0x09); + WHITESPACECHAR.set(0x0A); + WHITESPACECHAR.set(0x0B); + WHITESPACECHAR.set(0x0C); + WHITESPACECHAR.set(0x0D); + WHITESPACECHAR.set(' '); + } + private StringUtil() { throw new AssertionError("Cannot instantiate StringUtil"); } @@ -63,8 +77,6 @@ private StringUtil() { * * @param s the string to convert to lower case * @return the lower case version of the string - * - * @since 2.0.0 */ public static @NonNull String toLowerCase(@NonNull String s) { int pos = indexOfFirstUpperCaseChar(s); @@ -87,8 +99,6 @@ private StringUtil() { * * @param source the string to decode * @return the percent decoded string - * - * @since 2.0.0 */ public static @NonNull String percentDecode(@NonNull final String source) { if (source.indexOf(PERCENT_CHAR) == -1) { @@ -118,8 +128,6 @@ private StringUtil() { * * @param source the string to encode * @return the percent encoded string - * - * @since 2.0.0 */ public static @NonNull String percentEncode(@NonNull final String source) { if (!shouldEncode(source)) { @@ -148,8 +156,6 @@ private StringUtil() { * * @param c the character to check * @return true if the character is a digit; otherwise, false - * - * @since 2.0.0 */ public static boolean isDigit(int c) { return (c >= '0' && c <= '9'); @@ -160,8 +166,6 @@ public static boolean isDigit(int c) { * * @param c the character to check * @return true if the character is valid for the package-url type; otherwise, false - * - * @since 2.0.0 */ public static boolean isValidCharForType(int c) { return (isAlphaNumeric(c) || c == '.' || c == '+' || c == '-'); @@ -172,8 +176,6 @@ public static boolean isValidCharForType(int c) { * * @param c the character to check * @return true if the character is valid for the package-url qualifier key; otherwise, false - * - * @since 2.0.0 */ public static boolean isValidCharForKey(int c) { return (isAlphaNumeric(c) || c == '.' || c == '_' || c == '-'); @@ -211,7 +213,13 @@ private static boolean shouldEncode(String s) { return false; } - private static boolean isAlpha(int c) { + /** + * Determines if the character is an ASCII alphabetic character, i.e., a lowercase or uppercase ASCII character. + * + * @param c the character to check + * @return whether the character is an ASCII alphabetic character + */ + public static boolean isAlpha(int c) { return (isLowerCase(c) || isUpperCase(c)); } @@ -227,6 +235,21 @@ private static boolean isLowerCase(int c) { return (c >= 'a' && c <= 'z'); } + /** + * Determines if the character is an ASCII whitespace character, i.e., + * one of {@code ['\f', '\t', '\n', '\r', '\v', ' ']}. + * + * @param c the character to check + * @return whether the character is an ASCII whitespace character + */ + public static boolean isWhitespace(int c) { + if (c < 0 || c >= NBITS) { + return false; + } + + return WHITESPACECHAR.get(c); + } + private static int toLowerCase(int c) { return isUpperCase(c) ? (c ^ 0x20) : c; } diff --git a/src/main/java/com/github/packageurl/spi/PackageTypeProvider.java b/src/main/java/com/github/packageurl/spi/PackageTypeProvider.java new file mode 100644 index 0000000..f9fc9f8 --- /dev/null +++ b/src/main/java/com/github/packageurl/spi/PackageTypeProvider.java @@ -0,0 +1,107 @@ +/* + * 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.spi; + +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import com.github.packageurl.internal.PackageTypeFactory; +import com.github.packageurl.internal.StringUtil; +import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * This interface defines a common interface for package type providers. Each package + * type provider must implement this interface to validate and normalize package URLs. + *

+ * Implementations of this interface are registered as OSGi services. + *

+ * The {@link PackageTypeFactory} class is responsible for loading and managing the available + * package type providers. + * These providers are used to validate and normalize package URLs based on their + * specific type. + * Classes must be added to {@code META-INF/services/com.github.packageurl.spi.PackageTypeProvider} in order for + * {@link java.util.ServiceLoader ServiceLoader} to find them. + * + */ +public interface PackageTypeProvider { + /** + * Validates the components of a package URL. + * + * @param type the type of the package + * @param namespace the namespace of the package + * @param name the name of the package + * @param version the version of the package + * @param qualifiers the qualifiers of the package + * @param subpath the subpath of the package + * @throws MalformedPackageURLException if the components are not valid + */ + default void validateComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException {} + + /** + * Normalizes the components of a package URL. + * + * @param type the type of the package + * @param namespace the namespace of the package + * @param name the name of the package + * @param version the version of the package + * @param qualifiers the qualifiers of the package + * @param subpath the subpath of the package + * @return a normalized PackageURL object + * @throws MalformedPackageURLException if the components are not valid + */ + default @NonNull PackageURL normalizeComponents( + @NonNull String type, + @Nullable String namespace, + @NonNull String name, + @Nullable String version, + @Nullable Map qualifiers, + @Nullable String subpath) + throws MalformedPackageURLException { + return new PackageURL(type, namespace, name, version, qualifiers, subpath); + } + + /** + * Returns the package type of this provider. + * + * @return the package type of this provider + */ + default @NonNull String getPackageType() { + String type = StringUtil.toLowerCase(getClass().getSimpleName()); + + try { + PackageTypeFactory.validateType(type); + } catch (MalformedPackageURLException e) { + throw new IllegalArgumentException( + "Package type provider name '" + type + "' is not a valid package type", e); + } + + return type; + } +} diff --git a/src/main/java/com/github/packageurl/spi/package-info.java b/src/main/java/com/github/packageurl/spi/package-info.java new file mode 100644 index 0000000..f956b89 --- /dev/null +++ b/src/main/java/com/github/packageurl/spi/package-info.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +/** + *

Java implementation of the Package-URL Specification SPI for PURL types.

+ *

https://raw.githubusercontent.com/package-url/purl-spec/refs/heads/main/PURL-TYPES.rst

+ */ +@Export +@NullMarked +package com.github.packageurl.spi; + +import org.jspecify.annotations.NullMarked; +import org.osgi.annotation.bundle.Export; diff --git a/src/main/resources/META-INF/services/com.github.packageurl.spi.PackageTypeProvider b/src/main/resources/META-INF/services/com.github.packageurl.spi.PackageTypeProvider new file mode 100644 index 0000000..1c4e86d --- /dev/null +++ b/src/main/resources/META-INF/services/com.github.packageurl.spi.PackageTypeProvider @@ -0,0 +1,24 @@ +com.github.packageurl.internal.PackageTypeProviders$Apk +com.github.packageurl.internal.PackageTypeProviders$Bitbucket +com.github.packageurl.internal.PackageTypeProviders$Bitnami +com.github.packageurl.internal.PackageTypeProviders$Cocoapods +com.github.packageurl.internal.PackageTypeProviders$Composer +com.github.packageurl.internal.PackageTypeProviders$Conan +com.github.packageurl.internal.PackageTypeProviders$Cpan +com.github.packageurl.internal.PackageTypeProviders$Cran +com.github.packageurl.internal.PackageTypeProviders$Deb +com.github.packageurl.internal.PackageTypeProviders$Generic +com.github.packageurl.internal.PackageTypeProviders$Github +com.github.packageurl.internal.PackageTypeProviders$Golang +com.github.packageurl.internal.PackageTypeProviders$Hackage +com.github.packageurl.internal.PackageTypeProviders$Hex +com.github.packageurl.internal.PackageTypeProviders$Huggingface +com.github.packageurl.internal.PackageTypeProviders$Luarocks +com.github.packageurl.internal.PackageTypeProviders$Maven +com.github.packageurl.internal.PackageTypeProviders$Mlflow +com.github.packageurl.internal.PackageTypeProviders$Oci +com.github.packageurl.internal.PackageTypeProviders$Pub +com.github.packageurl.internal.PackageTypeProviders$Pypi +com.github.packageurl.internal.PackageTypeProviders$Qpkg +com.github.packageurl.internal.PackageTypeProviders$Rpm +com.github.packageurl.internal.PackageTypeProviders$Swift diff --git a/src/test/java/com/github/packageurl/PackageURLTest.java b/src/test/java/com/github/packageurl/PackageURLTest.java index 71d42ea..8a87107 100644 --- a/src/test/java/com/github/packageurl/PackageURLTest.java +++ b/src/test/java/com/github/packageurl/PackageURLTest.java @@ -21,9 +21,9 @@ */ package com.github.packageurl; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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; @@ -38,6 +38,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; /** * Test cases for PackageURL parsing @@ -97,10 +98,10 @@ void constructorParsing( boolean invalid) throws Exception { if (invalid) { - assertThrows( + assertThrowsExactly( getExpectedException(purlString), - () -> new PackageURL(purlString), - "Build should fail due to " + description); + () -> new PackageURL(purlString).normalize(), + "Parsing '" + purlString + "' should have failed because " + description); } else { PackageURL purl = new PackageURL(purlString); assertPurlEquals(parameters, purl); @@ -124,7 +125,7 @@ void constructorParameters( boolean invalid) throws Exception { if (invalid) { - assertThrows( + assertThrowsExactly( getExpectedException(parameters), () -> new PackageURL( parameters.getType(), @@ -161,7 +162,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()); @@ -220,6 +221,7 @@ void standardTypes() { assertEquals("luarocks", PackageURL.StandardTypes.LUAROCKS); assertEquals("maven", PackageURL.StandardTypes.MAVEN); assertEquals("mlflow", PackageURL.StandardTypes.MLFLOW); + assertEquals("nix", PackageURL.StandardTypes.NIX); assertEquals("npm", PackageURL.StandardTypes.NPM); assertEquals("nuget", PackageURL.StandardTypes.NUGET); assertEquals("qpkg", PackageURL.StandardTypes.QPKG); @@ -272,4 +274,107 @@ void npmCaseSensitive() throws Exception { assertEquals("Base64", base64Uppercase.getName()); assertEquals("1.0.0", base64Uppercase.getVersion()); } + + @ParameterizedTest + @ValueSource( + strings = { + "pkg:alpm/arch/pacman@6.0.1-1?arch=x86_64", + "pkg:alpm/arch/python-pip@21.0-1?arch=any", + "pkg:alpm/arch/containers-common@1:0.47.4-4?arch=x86_64", + "pkg:apk/alpine/curl@7.83.0-r0?arch=x86", + "pkg:apk/alpine/apk@2.12.9-r3?arch=x86", + "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c", + "pkg:bitnami/wordpress?distro=debian-12", + "pkg:bitnami/wordpress@6.2.0?distro=debian-12", + "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=debian-12", + "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=photon-4", + "pkg:cocoapods/AFNetworking@4.0.1", + "pkg:cocoapods/MapsIndoors@3.24.0", + "pkg:cocoapods/ShareKit@2.0#Twitter", + "pkg:cocoapods/GoogleUtilities@7.5.2#NSData+zlib", + "pkg:cocoapods/GoogleUtilities@7.5.2#NSData+zlib", + "pkg:cargo/rand@0.7.2", + "pkg:cargo/clap@2.33.0", + "pkg:cargo/structopt@0.3.11", + "pkg:composer/laravel/laravel@5.5.0", + "pkg:conan/openssl@3.0.3", + "pkg:conan/openssl.org/openssl@3.0.3?user=bincrafters&channel=stable", + "pkg:conan/openssl.org/openssl@3.0.3?arch=x86_64&build_type=Debug&compiler=Visual%20Studio&compiler.runtime=MDd&compiler.version=16&os=Windows&shared=True&rrev=93a82349c31917d2d674d22065c7a9ef9f380c8e&prev=b429db8a0e324114c25ec387bfd8281f330d7c5c", + "pkg:conda/absl-py@0.4.1?build=py36h06a4308_0&channel=main&subdir=linux-64&type=tar.bz2", + "pkg:cpan/Perl::Version@1.013", + "pkg:cpan/DROLSKY/DateTime@1.55", + "pkg:cpan/DateTime@1.55", + "pkg:cpan/GDT/URI-PackageURL", + "pkg:cpan/LWP::UserAgent", + "pkg:cpan/OALDERS/libwww-perl@6.76", + "pkg:cpan/URI", + "pkg:cran/A3@1.0.0", + "pkg:cran/rJava@1.0-4", + "pkg:cran/caret@6.0-88", + "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", + "pkg:deb/debian/dpkg@1.19.0.4?arch=amd64&distro=stretch", + "pkg:deb/ubuntu/dpkg@1.19.0.4?arch=amd64", + "pkg:deb/debian/attr@1:2.4.47-2?arch=source", + "pkg:deb/debian/attr@1:2.4.47-2%2Bb1?arch=amd64", + "pkg:docker/cassandra@latest", + "pkg:docker/smartentry/debian@dc437cc87d10", + "pkg:docker/customer/dockerimage@sha256%3A244fd47e07d10?repository_url=gcr.io", + "pkg:gem/ruby-advisory-db-check@0.12.4", + "pkg:gem/jruby-launcher@1.1.2?platform=java", + "pkg:generic/openssl@1.1.10g", + "pkg:generic/openssl@1.1.10g?download_url=https://openssl.org/source/openssl-1.1.0g.tar.gz&checksum=sha256:de4d501267da", + "pkg:generic/bitwarderl?vcs_url=git%2Bhttps://git.fsfe.org/dxtr/bitwarderl%40cc55108da32", + "pkg:github/package-url/purl-spec@244fd47e07d1004", + "pkg:github/package-url/purl-spec@244fd47e07d1004#everybody/loves/dogs", + "pkg:golang/github.com/gorilla/context@234fd47e07d1004f0aed9c", + "pkg:golang/google.golang.org/genproto#googleapis/api/annotations", + "pkg:golang/github.com/gorilla/context@234fd47e07d1004f0aed9c#api", + "pkg:hackage/a50@0.5", + "pkg:hackage/AC-HalfInteger@1.2.1", + "pkg:hackage/3d-graphics-examples@0.0.0.2", + "pkg:hex/jason@1.1.2", + "pkg:hex/acme/foo@2.3.", + "pkg:hex/phoenix_html@2.13.3#priv/static/phoenix_html.js", + "pkg:hex/bar@1.2.3?repository_url=https://myrepo.example.com", + "pkg:huggingface/distilbert-base-uncased@043235d6088ecd3dd5fb5ca3592b6913fd516027", + "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https://hub-ci.huggingface.co", + "pkg:luarocks/luasocket@3.1.0-1", + "pkg:luarocks/hisham/luafilesystem@1.8.0-1", + "pkg:luarocks/username/packagename@0.1.0-1?repository_url=https://example.com/private_rocks_server/", + "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1", + "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=pom", + "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources", + "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=zip&classifier=dist", + "pkg:maven/net.sf.jacob-projec/jacob@1.14.3?classifier=x86&type=dll", + "pkg:maven/net.sf.jacob-projec/jacob@1.14.3?classifier=x64&type=dll", + "pkg:maven/groovy/groovy@1.0?repository_url=https://maven.google.com", + "pkg:mlflow/creditfraud@3?repository_url=https://westus2.api.azureml.ms/mlflow/v1.0/subscriptions/a50f2011-fab8-4164-af23-c62881ef8c95/resourceGroups/TestResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/TestWorkspace", + "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a&repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow", + "pkg:npm/foobar@12.3.1", + "pkg:npm/%40angular/animation@12.3.1", + "pkg:npm/mypackage@12.4.5?vcs_url=git://host.com/path/to/repo.git%404345abcd34343", + "pkg:nuget/EnterpriseLibrary.Common@6.0.1304", + "pkg:qpkg/blackberry/com.qnx.sdp@7.0.0.SGA201702151847", + "pkg:qpkg/blackberry/com.qnx.qnx710.foo.bar.qux@0.0.4.01449T202205040833L", + "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=docker.io/library/debian&arch=amd64&tag=latest", + "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io/debian&tag=bullseye", + "pkg:oci/static@sha256%3A244fd47e07d10?repository_url=gcr.io/distroless/static&tag=latest", + "pkg:oci/hello-wasm@sha256%3A244fd47e07d10?tag=v1", + "pkg:pub/characters@1.2.0", + "pkg:pub/flutter@0.0.0", + "pkg:pypi/django@1.11.1", + "pkg:pypi/django@1.11.1?filename=Django-1.11.1.tar.gz", + "pkg:pypi/django@1.11.1?filename=Django-1.11.1-py2.py3-none-any.whl", + "pkg:pypi/django-allauth@12.23", + "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25", + "pkg:rpm/centerim@4.22.10-1.el6?arch=i686&epoch=1&distro=fedora-25", + "pkg:swid/Acme/example.com/Enterprise+Server@1.0.0?tag_id=75b8c285-fa7b-485b-b199-4745e3004d0d", + "pkg:swid/Fedora@29?tag_id=org.fedoraproject.Fedora-29", + "pkg:swid/Adobe+Systems+Incorporated/Adobe+InDesign@CC?tag_id=CreativeCloud-CS6-Win-GM-MUL", + "pkg:swift/github.com/Alamofire/Alamofire@5.4.3", + "pkg:swift/github.com/RxSwiftCommunity/RxFlow@2.12.4" + }) + void parseValidTypes(final String purl) { + assertDoesNotThrow(() -> new PackageURL(purl).normalize()); + } }