3131import java .util .Map ;
3232import java .util .Objects ;
3333import java .util .TreeMap ;
34+ import java .util .function .IntPredicate ;
3435import java .util .regex .Pattern ;
3536import java .util .stream .Collectors ;
3637
@@ -98,13 +99,31 @@ public PackageURL(final String type, final String namespace, final String name,
9899 this .namespace = validateNamespace (namespace );
99100 this .name = validateName (name );
100101 this .version = validateVersion (version );
101- this .qualifiers = validateQualifiers (qualifiers );
102+ this .qualifiers = parseQualifiers (qualifiers );
102103 this .subpath = validatePath (subpath , true );
103104 verifyTypeConstraints (this .type , this .namespace , this .name );
104105 }
105106
106107 /**
107- * The PackageURL scheme constant.
108+ * Constructs a new PackageURL object.
109+ *
110+ * @param type the type of package (i.e. maven, npm, gem, etc)
111+ * @param namespace the name prefix (i.e. group, owner, organization)
112+ * @param name the name of the package
113+ * @param version the version of the package
114+ * @param qualifiers an array of key/value pair qualifiers
115+ * @param subpath the subpath string
116+ * @throws MalformedPackageURLException if parsing fails
117+ * @since 1.6.0
118+ */
119+ public PackageURL (final String type , final String namespace , final String name , final String version ,
120+ final Map <String , String > qualifiers , final String subpath )
121+ throws MalformedPackageURLException {
122+ this (type , namespace , name , version , (qualifiers != null ) ? new TreeMap <>(qualifiers ) : null , subpath );
123+ }
124+
125+ /**
126+ * The PackageURL scheme constant
108127 */
109128 public static final String SCHEME = "pkg" ;
110129
@@ -223,7 +242,7 @@ public String getVersion() {
223242 * @since 1.0.0
224243 */
225244 public Map <String , String > getQualifiers () {
226- return (qualifiers != null )? Collections .unmodifiableMap (qualifiers ) : null ;
245+ return (qualifiers != null ) ? Collections .unmodifiableMap (qualifiers ) : null ;
227246 }
228247
229248 /**
@@ -247,18 +266,31 @@ private String validateType(final String value) throws MalformedPackageURLExcept
247266 throw new MalformedPackageURLException ("The PackageURL type cannot be null or empty" );
248267 }
249268
250- if (isDigit (value .charAt (0 ))) {
251- throw new MalformedPackageURLException ("The PackageURL type cannot start with a number" );
252- }
269+ validateChars (value , PackageURL ::isValidCharForType , "type" );
270+
271+ return value ;
272+ }
273+
274+ private static boolean isValidCharForType (int c ) {
275+ return (isAlphaNumeric (c ) || c == '.' || c == '+' || c == '-' );
276+ }
253277
254- if (!value .chars ().allMatch (c -> (c == '.' || c == '+' || c == '-'
255- || isUpperCase (c )
256- || isLowerCase (c )
257- || isDigit (c )))) {
258- throw new MalformedPackageURLException ("The PackageURL type contains invalid characters" );
278+ private static boolean isValidCharForKey (int c ) {
279+ return (isAlphaNumeric (c ) || c == '.' || c == '_' || c == '-' );
280+ }
281+
282+ private static void validateChars (String value , IntPredicate predicate , String component ) throws MalformedPackageURLException {
283+ char firstChar = value .charAt (0 );
284+
285+ if (isDigit (firstChar )) {
286+ throw new MalformedPackageURLException ("The PackageURL " + component + " cannot start with a number: " + firstChar );
259287 }
260288
261- return value ;
289+ String invalidChars = value .chars ().filter (predicate .negate ()).mapToObj (c -> String .valueOf ((char ) c )).collect (Collectors .joining (", " ));
290+
291+ if (!invalidChars .isEmpty ()) {
292+ throw new MalformedPackageURLException ("The PackageURL " + component + " '" + value + "' contains invalid characters: " + invalidChars );
293+ }
262294 }
263295
264296 private String validateNamespace (final String value ) throws MalformedPackageURLException {
@@ -319,7 +351,7 @@ private String validateVersion(final String value) {
319351 }
320352
321353 private Map <String , String > validateQualifiers (final Map <String , String > values ) throws MalformedPackageURLException {
322- if (values == null ) {
354+ if (values == null || values . isEmpty () ) {
323355 return null ;
324356 }
325357 for (Map .Entry <String , String > entry : values .entrySet ()) {
@@ -337,10 +369,7 @@ private void validateKey(final String value) throws MalformedPackageURLException
337369 throw new MalformedPackageURLException ("Qualifier key is invalid: " + value );
338370 }
339371
340- if (isDigit (value .charAt (0 ))
341- || !value .chars ().allMatch (c -> isLowerCase (c ) || (isDigit (c )) || c == '.' || c == '-' || c == '_' )) {
342- throw new MalformedPackageURLException ("Qualifier key is invalid: " + value );
343- }
372+ validateChars (value , PackageURL ::isValidCharForKey , "qualifier key" );
344373 }
345374
346375 private String validatePath (final String value , final boolean isSubpath ) throws MalformedPackageURLException {
@@ -463,7 +492,7 @@ private static String uriEncode(String source, Charset charset) {
463492 }
464493
465494 private static boolean isUnreserved (int c ) {
466- return (isAlpha (c ) || isDigit ( c ) || '-' == c || '.' == c || '_' == c || '~' == c );
495+ return (isValidCharForKey (c ) || c == '~' );
467496 }
468497
469498 private static boolean isAlpha (int c ) {
@@ -474,6 +503,10 @@ private static boolean isDigit(int c) {
474503 return (c >= '0' && c <= '9' );
475504 }
476505
506+ private static boolean isAlphaNumeric (int c ) {
507+ return (isDigit (c ) || isAlpha (c ));
508+ }
509+
477510 private static boolean isUpperCase (int c ) {
478511 return (c >= 'A' && c <= 'Z' );
479512 }
@@ -656,6 +689,23 @@ private void verifyTypeConstraints(String type, String namespace, String name) t
656689 }
657690 }
658691
692+ private Map <String , String > parseQualifiers (final Map <String , String > qualifiers ) throws MalformedPackageURLException {
693+ if (qualifiers == null || qualifiers .isEmpty ()) {
694+ return null ;
695+ }
696+
697+ try {
698+ final TreeMap <String , String > results = qualifiers .entrySet ().stream ()
699+ .filter (entry -> entry .getValue () != null && !entry .getValue ().isEmpty ())
700+ .collect (TreeMap ::new ,
701+ (map , value ) -> map .put (toLowerCase (value .getKey ()), value .getValue ()),
702+ TreeMap ::putAll );
703+ return validateQualifiers (results );
704+ } catch (ValidationException ex ) {
705+ throw new MalformedPackageURLException (ex .getMessage ());
706+ }
707+ }
708+
659709 @ SuppressWarnings ("StringSplitter" )//reason: surprising behavior is okay in this case
660710 private Map <String , String > parseQualifiers (final String encodedString ) throws MalformedPackageURLException {
661711 try {
0 commit comments