2121 */
2222package com .github .packageurl ;
2323
24+ import java .io .ByteArrayOutputStream ;
2425import java .io .Serializable ;
2526import java .net .URI ;
2627import java .net .URISyntaxException ;
27- import java .nio .charset . Charset ;
28+ import java .nio .ByteBuffer ;
2829import java .nio .charset .StandardCharsets ;
2930import java .util .Arrays ;
3031import java .util .Collections ;
3435import java .util .function .IntPredicate ;
3536import java .util .regex .Pattern ;
3637import java .util .stream .Collectors ;
38+ import java .util .stream .IntStream ;
3739
3840/**
3941 * <p>Package-URL (aka purl) is a "mostly universal" URL to describe a package. A purl is a URL composed of seven components:</p>
@@ -55,6 +57,7 @@ public final class PackageURL implements Serializable {
5557
5658 private static final long serialVersionUID = 3243226021636427586L ;
5759 private static final Pattern PATH_SPLITTER = Pattern .compile ("/" );
60+ private static final char PERCENT_CHAR = '%' ;
5861
5962 /**
6063 * Constructs a new PackageURL object by parsing the specified string.
@@ -459,39 +462,14 @@ private String canonicalize(boolean coordinatesOnly) {
459462 return purl .toString ();
460463 }
461464
462- /**
463- * Encodes the input in conformance with RFC 3986.
464- *
465- * @param input the String to encode
466- * @return an encoded String
467- */
468- private String percentEncode (final String input ) {
469- return uriEncode (input , StandardCharsets .UTF_8 );
470- }
471-
472- private static String uriEncode (String source , Charset charset ) {
473- if (source == null || source .isEmpty ()) {
474- return source ;
475- }
476-
477- StringBuilder builder = new StringBuilder ();
478- for (byte b : source .getBytes (charset )) {
479- if (isUnreserved (b )) {
480- builder .append ((char ) b );
481- }
482- else {
483- // Substitution: A '%' followed by the hexadecimal representation of the ASCII value of the replaced character
484- builder .append ('%' );
485- builder .append (Integer .toHexString (b ).toUpperCase ());
486- }
487- }
488- return builder .toString ();
489- }
490-
491465 private static boolean isUnreserved (int c ) {
492466 return (isValidCharForKey (c ) || c == '~' );
493467 }
494468
469+ private static boolean shouldEncode (int c ) {
470+ return !isUnreserved (c );
471+ }
472+
495473 private static boolean isAlpha (int c ) {
496474 return (isLowerCase (c ) || isUpperCase (c ));
497475 }
@@ -547,42 +525,93 @@ private static String toLowerCase(String s) {
547525 return new String (chars );
548526 }
549527
550- /**
551- * Optionally decodes a String, if it's encoded. If String is not encoded,
552- * method will return the original input value.
553- *
554- * @param input the value String to decode
555- * @return a decoded String
556- */
557- private String percentDecode (final String input ) {
558- if (input == null ) {
559- return null ;
560- }
561- final String decoded = uriDecode (input );
562- if (!decoded .equals (input )) {
563- return decoded ;
528+ private static String percentDecode (final String source ) {
529+ if (source == null || source .isEmpty ()) {
530+ return source ;
564531 }
565- return input ;
566- }
567532
568- public static String uriDecode (String source ) {
569- if (source == null ) {
533+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
534+ int percentCharCount = getPercentCharCount (bytes );
535+
536+ if (percentCharCount == 0 ) {
570537 return source ;
571538 }
572- int length = source .length ();
573- StringBuilder builder = new StringBuilder ();
539+
540+ int length = bytes .length ;
541+ int capacity = (length + percentCharCount ) - (percentCharCount * 3 );
542+
543+ if (capacity <= 0 ) {
544+ throw new ValidationException ("Invalid encoding in '" + source + "'" );
545+ }
546+
547+ ByteBuffer buffer = ByteBuffer .allocate (capacity );
548+
574549 for (int i = 0 ; i < length ; i ++) {
575- if (source .charAt (i ) == '%' ) {
576- String str = source .substring (i + 1 , i + 3 );
577- char c = (char ) Integer .parseInt (str , 16 );
578- builder .append (c );
579- i += 2 ;
550+ int b ;
551+
552+ if (bytes [i ] == PERCENT_CHAR ) {
553+ int b1 = Character .digit (bytes [++i ], 16 );
554+ int b2 = Character .digit (bytes [++i ], 16 );
555+ b = (byte ) ((b1 << 4 ) + b2 );
556+ } else {
557+ b = bytes [i ];
580558 }
581- else {
582- builder .append (source .charAt (i ));
559+
560+ if (buffer .position () + 1 > capacity ) {
561+ throw new ValidationException ("Invalid encoding in '" + source + "'" );
583562 }
563+
564+ buffer .put ((byte ) b );
584565 }
585- return builder .toString ();
566+
567+ return new String (buffer .array (), StandardCharsets .UTF_8 );
568+ }
569+
570+ @ Deprecated
571+ public String uriDecode (final String source ) {
572+ return percentDecode (source );
573+ }
574+
575+ private static int getUnsafeCharCount (final byte [] bytes ) {
576+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::shouldEncode ).count ();
577+ }
578+
579+ private static boolean isPercent (int c ) {
580+ return (c == PERCENT_CHAR );
581+ }
582+
583+ private static int getPercentCharCount (final byte [] bytes ) {
584+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::isPercent ).count ();
585+ }
586+
587+ private static String percentEncode (final String source ) {
588+ if (source == null || source .isEmpty ()) {
589+ return source ;
590+ }
591+
592+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
593+ int unsafeCharCount = getUnsafeCharCount (bytes );
594+
595+ if (unsafeCharCount == 0 ) {
596+ return source ;
597+ }
598+
599+ int length = bytes .length ;
600+ int capacity = (length - unsafeCharCount ) + (3 * unsafeCharCount );
601+ ByteBuffer buffer = ByteBuffer .allocate (capacity );
602+
603+ for (byte b : bytes ) {
604+ if (shouldEncode (b )) {
605+ byte b1 = (byte ) Character .toUpperCase (Character .forDigit ((b >> 4 ) & 0xF , 16 ));
606+ byte b2 = (byte ) Character .toUpperCase (Character .forDigit (b & 0xF , 16 ));
607+ byte [] encoded = {(byte ) PERCENT_CHAR , b1 , b2 };
608+ buffer .put (encoded , 0 , encoded .length );
609+ } else {
610+ buffer .put (b );
611+ }
612+ }
613+
614+ return new String (buffer .array (), StandardCharsets .UTF_8 );
586615 }
587616
588617 /**
@@ -652,16 +681,16 @@ private void parse(final String purl) throws MalformedPackageURLException {
652681 // version is optional - check for existence
653682 index = remainder .lastIndexOf ('@' );
654683 if (index >= start ) {
655- this .version = validateVersion (percentDecode (remainder .substring (index + 1 )));
684+ this .version = validateVersion (uriDecode (remainder .substring (index + 1 )));
656685 remainder = remainder .substring (0 , index );
657686 }
658687
659688 // The 'remainder' should now consist of an optional namespace and the name
660689 index = remainder .lastIndexOf ('/' );
661690 if (index <= start ) {
662- this .name = validateName (percentDecode (remainder .substring (start )));
691+ this .name = validateName (uriDecode (remainder .substring (start )));
663692 } else {
664- this .name = validateName (percentDecode (remainder .substring (index + 1 )));
693+ this .name = validateName (uriDecode (remainder .substring (index + 1 )));
665694 remainder = remainder .substring (0 , index );
666695 this .namespace = validateNamespace (parsePath (remainder .substring (start ), false ));
667696 }
@@ -712,7 +741,7 @@ private Map<String, String> parseQualifiers(final String encodedString) throws M
712741 final String [] entry = value .split ("=" , 2 );
713742 if (entry .length == 2 && !entry [1 ].isEmpty ()) {
714743 String key = toLowerCase (entry [0 ]);
715- if (map .put (key , percentDecode (entry [1 ])) != null ) {
744+ if (map .put (key , uriDecode (entry [1 ])) != null ) {
716745 throw new ValidationException ("Duplicate package qualifier encountered. More then one value was specified for " + key );
717746 }
718747 }
@@ -731,7 +760,7 @@ private String[] parsePath(final String value, final boolean isSubpath) {
731760 }
732761 return PATH_SPLITTER .splitAsStream (value )
733762 .filter (segment -> !segment .isEmpty () && !(isSubpath && ("." .equals (segment ) || ".." .equals (segment ))))
734- .map (segment -> percentDecode (segment ))
763+ .map (segment -> uriDecode (segment ))
735764 .toArray (String []::new );
736765 }
737766
0 commit comments