Skip to content

Commit dd1bbd8

Browse files
committed
package-urlGH-188: Implement package type providers
This uses the Java Service Provider Interface via `ServiceLoader` to register new types, where each type gets its own class.
1 parent 062af5f commit dd1bbd8

36 files changed

+1547
-33
lines changed

Diff for: src/main/java/com/github/packageurl/PackageURL.java

+36-32
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import static java.util.Objects.requireNonNull;
2525

26+
import com.github.packageurl.type.PackageTypeFactory;
2627
import java.io.Serializable;
2728
import java.net.URI;
2829
import java.net.URISyntaxException;
@@ -77,34 +78,34 @@ public final class PackageURL implements Serializable {
7778
private final String type;
7879

7980
/**
80-
* The name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization.
81+
* The name prefix such as a Maven groupId, a Docker image owner, a GitHub user or organization.
8182
* Optional and type-specific.
8283
*/
83-
private final @Nullable String namespace;
84+
private @Nullable String namespace;
8485

8586
/**
8687
* The name of the package.
8788
* Required.
8889
*/
89-
private final String name;
90+
private String name;
9091

9192
/**
9293
* The version of the package.
9394
* Optional.
9495
*/
95-
private final @Nullable String version;
96+
private @Nullable String version;
9697

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

103104
/**
104105
* Extra subpath within a package, relative to the package root.
105106
* Optional.
106107
*/
107-
private final @Nullable String subpath;
108+
private @Nullable String subpath;
108109

109110
/**
110111
* Constructs a new PackageURL object by parsing the specified string.
@@ -194,7 +195,6 @@ public PackageURL(final String purl) throws MalformedPackageURLException {
194195
remainder = remainder.substring(0, index);
195196
this.namespace = validateNamespace(this.type, parsePath(remainder.substring(start), false));
196197
}
197-
verifyTypeConstraints(this.type, this.namespace, this.name);
198198
} catch (URISyntaxException e) {
199199
throw new MalformedPackageURLException("Invalid purl: " + e.getMessage(), e);
200200
}
@@ -264,7 +264,6 @@ public PackageURL(
264264
this.version = validateVersion(this.type, version);
265265
this.qualifiers = parseQualifiers(qualifiers);
266266
this.subpath = validateSubpath(subpath);
267-
verifyTypeConstraints(this.type, this.namespace, this.name);
268267
}
269268

270269
/**
@@ -360,11 +359,11 @@ private static String validateType(final String value) throws MalformedPackageUR
360359
return value;
361360
}
362361

363-
private static boolean isValidCharForType(int c) {
362+
public static boolean isValidCharForType(int c) {
364363
return (isAlphaNumeric(c) || c == '.' || c == '+' || c == '-');
365364
}
366365

367-
private static boolean isValidCharForKey(int c) {
366+
public static boolean isValidCharForKey(int c) {
368367
return (isAlphaNumeric(c) || c == '.' || c == '_' || c == '-');
369368
}
370369

@@ -538,6 +537,12 @@ private static void validateValue(final String key, final @Nullable String value
538537
}
539538
}
540539

540+
public PackageURL normalize() throws MalformedPackageURLException {
541+
PackageTypeFactory.getInstance().validateComponents(type, namespace, name, version, qualifiers, subpath);
542+
return PackageTypeFactory.getInstance()
543+
.normalizeComponents(type, namespace, name, version, qualifiers, subpath);
544+
}
545+
541546
/**
542547
* Returns the canonicalized representation of the purl.
543548
*
@@ -565,6 +570,17 @@ public String canonicalize() {
565570
* @since 1.3.2
566571
*/
567572
private String canonicalize(boolean coordinatesOnly) {
573+
try {
574+
PackageURL packageURL = normalize();
575+
namespace = packageURL.getNamespace();
576+
name = packageURL.getName();
577+
version = packageURL.getVersion();
578+
qualifiers = packageURL.getQualifiers();
579+
subpath = packageURL.getSubpath();
580+
} catch (MalformedPackageURLException e) {
581+
throw new ValidationException("Normalization failed", e);
582+
}
583+
568584
final StringBuilder purl = new StringBuilder();
569585
purl.append(SCHEME_PART).append(type).append('/');
570586
if (namespace != null) {
@@ -577,7 +593,7 @@ private String canonicalize(boolean coordinatesOnly) {
577593
}
578594

579595
if (!coordinatesOnly) {
580-
if (qualifiers != null) {
596+
if (!qualifiers.isEmpty()) {
581597
purl.append('?');
582598
Set<Map.Entry<String, String>> entries = qualifiers.entrySet();
583599
boolean separator = false;
@@ -606,18 +622,22 @@ private static boolean shouldEncode(int c) {
606622
return !isUnreserved(c);
607623
}
608624

609-
private static boolean isAlpha(int c) {
625+
public static boolean isAlpha(int c) {
610626
return (isLowerCase(c) || isUpperCase(c));
611627
}
612628

613629
private static boolean isDigit(int c) {
614630
return (c >= '0' && c <= '9');
615631
}
616632

617-
private static boolean isAlphaNumeric(int c) {
633+
public static boolean isAlphaNumeric(int c) {
618634
return (isDigit(c) || isAlpha(c));
619635
}
620636

637+
public static boolean isWhitespace(int c) {
638+
return (c == ' ' || c == '\t' || c == '\r' || c == '\n');
639+
}
640+
621641
private static boolean isUpperCase(int c) {
622642
return (c >= 'A' && c <= 'Z');
623643
}
@@ -642,7 +662,7 @@ private static int toLowerCase(int c) {
642662
return isUpperCase(c) ? (c ^ 0x20) : c;
643663
}
644664

645-
static String toLowerCase(String s) {
665+
public static String toLowerCase(String s) {
646666
int pos = indexOfFirstUpperCaseChar(s);
647667

648668
if (pos == -1) {
@@ -770,22 +790,6 @@ static String percentEncode(final String source) {
770790
return changed ? new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8) : source;
771791
}
772792

773-
/**
774-
* Some purl types may have specific constraints. This method attempts to verify them.
775-
* @param type the purl type
776-
* @param namespace the purl namespace
777-
* @throws MalformedPackageURLException if constraints are not met
778-
*/
779-
private static void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name)
780-
throws MalformedPackageURLException {
781-
if (StandardTypes.MAVEN.equals(type)) {
782-
if (isEmpty(namespace) || isEmpty(name)) {
783-
throw new MalformedPackageURLException(
784-
"The PackageURL specified is invalid. Maven requires both a namespace and name.");
785-
}
786-
}
787-
}
788-
789793
private static @Nullable Map<String, String> parseQualifiers(final @Nullable Map<String, String> qualifiers)
790794
throws MalformedPackageURLException {
791795
if (qualifiers == null || qualifiers.isEmpty()) {
@@ -1107,15 +1111,15 @@ public static final class StandardTypes {
11071111
* @deprecated use {@link #DEB} instead
11081112
*/
11091113
@Deprecated
1110-
public static final String DEBIAN = "deb";
1114+
public static final String DEBIAN = DEB;
11111115
/**
11121116
* Nixos packages.
11131117
*
11141118
* @since 1.1.0
11151119
* @deprecated use {@link #NIX} instead
11161120
*/
11171121
@Deprecated
1118-
public static final String NIXPKGS = "nix";
1122+
public static final String NIXPKGS = NIX;
11191123

11201124
private StandardTypes() {}
11211125
}

Diff for: src/main/java/com/github/packageurl/ValidationException.java

+4
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ class ValidationException extends RuntimeException {
3838
ValidationException(String msg) {
3939
super(msg);
4040
}
41+
42+
ValidationException(String msg, Throwable cause) {
43+
super(msg, cause);
44+
}
4145
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
public class ApkPackageTypeProvider extends LowercaseNamespaceAndNameTypeProvider {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
public class BitbucketPackageTypeProvider extends LowercaseNamespaceAndNameTypeProvider {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
public class BitnamiPackageTypeProvider extends LowercaseNamespacePackageTypeProvider {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
import com.github.packageurl.MalformedPackageURLException;
25+
import com.github.packageurl.PackageURL;
26+
import java.util.Map;
27+
import org.jspecify.annotations.NonNull;
28+
import org.jspecify.annotations.Nullable;
29+
30+
public class CocoapodsPackageTypeProvider implements PackageTypeProvider {
31+
@Override
32+
public void validateComponents(
33+
@NonNull String type,
34+
@Nullable String namespace,
35+
@NonNull String name,
36+
@Nullable String version,
37+
@Nullable Map<String, String> qualifiers,
38+
@Nullable String subpath)
39+
throws MalformedPackageURLException {
40+
if (namespace != null && !namespace.isEmpty()) {
41+
throw new MalformedPackageURLException("invalid cocoapods purl cannot have a namespace");
42+
}
43+
44+
if (name.chars().anyMatch(PackageURL::isWhitespace) || name.startsWith(".") || name.contains("+")) {
45+
throw new MalformedPackageURLException("invalid cocoapods purl invalid name");
46+
}
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
public class ComposerPackageTypeProvider extends LowercaseNamespaceAndNameTypeProvider {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl.type;
23+
24+
import com.github.packageurl.MalformedPackageURLException;
25+
import java.util.Map;
26+
import org.jspecify.annotations.NonNull;
27+
import org.jspecify.annotations.Nullable;
28+
29+
public class ConanPackageTypeProvider implements PackageTypeProvider {
30+
@Override
31+
public void validateComponents(
32+
@NonNull String type,
33+
@Nullable String namespace,
34+
@NonNull String name,
35+
@Nullable String version,
36+
@Nullable Map<String, String> qualifiers,
37+
@Nullable String subpath)
38+
throws MalformedPackageURLException {
39+
boolean hasChannel = (qualifiers != null && !qualifiers.isEmpty());
40+
41+
if ((namespace != null && !namespace.isEmpty()) && !hasChannel) {
42+
throw new MalformedPackageURLException("invalid conan purl only namespace");
43+
} else if ((namespace == null || namespace.isEmpty()) && hasChannel) {
44+
throw new MalformedPackageURLException("invalid conan purl only channel qualifier");
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)