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>
@@ -459,39 +461,14 @@ private String canonicalize(boolean coordinatesOnly) {
459461 return purl .toString ();
460462 }
461463
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-
491464 private static boolean isUnreserved (int c ) {
492465 return (isValidCharForKey (c ) || c == '~' );
493466 }
494467
468+ private static boolean shouldEncode (int c ) {
469+ return !isUnreserved (c );
470+ }
471+
495472 private static boolean isAlpha (int c ) {
496473 return (isLowerCase (c ) || isUpperCase (c ));
497474 }
@@ -547,42 +524,81 @@ private static String toLowerCase(String s) {
547524 return new String (chars );
548525 }
549526
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 ;
527+ private static String percentDecode (final String source ) {
528+ if (source == null || source .isEmpty ()) {
529+ return source ;
564530 }
565- return input ;
566- }
567531
568- public static String uriDecode (String source ) {
569- if (source == null ) {
532+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
533+ int percentCharCount = getPercentCharCount (bytes );
534+
535+ if (percentCharCount == 0 ) {
570536 return source ;
571537 }
572- int length = source .length ();
573- StringBuilder builder = new StringBuilder ();
538+
539+ int length = bytes .length ;
540+ int capacity = (length + percentCharCount ) - (percentCharCount * 3 );
541+ ByteBuffer buffer = ByteBuffer .allocate (capacity );
542+
574543 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 ;
544+ int b = bytes [i ];
545+
546+ if (b == '%' ) {
547+ int b1 = Character .digit (bytes [++i ], 16 );
548+ int b2 = Character .digit (bytes [++i ], 16 );
549+ buffer .put ((byte ) ((b1 << 4 ) + b2 ));
550+ } else {
551+ buffer .put ((byte ) b );
580552 }
581- else {
582- builder .append (source .charAt (i ));
553+ }
554+
555+ return new String (buffer .array (),StandardCharsets .UTF_8 );
556+ }
557+
558+ @ Deprecated
559+ public String uriDecode (final String source ) {
560+ return percentDecode (source );
561+ }
562+
563+ private static int getUnsafeCharCount (final byte [] bytes ) {
564+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::shouldEncode ).count ();
565+ }
566+
567+ private static boolean isPercent (int c ) {
568+ return (c == '%' );
569+ }
570+
571+ private static int getPercentCharCount (final byte [] bytes ) {
572+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::isPercent ).count ();
573+ }
574+
575+ private static String percentEncode (final String source ) {
576+ if (source == null || source .isEmpty ()) {
577+ return source ;
578+ }
579+
580+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
581+ int unsafeCharCount = getUnsafeCharCount (bytes );
582+
583+ if (unsafeCharCount == 0 ) {
584+ return source ;
585+ }
586+
587+ int length = bytes .length ;
588+ int capacity = (length - unsafeCharCount ) + (3 * unsafeCharCount );
589+ ByteBuffer bb = ByteBuffer .allocate (capacity );
590+
591+ for (byte b : bytes ) {
592+ if (shouldEncode (b )) {
593+ bb .put ((byte ) '%' );
594+ bb .put ((byte ) Character .toUpperCase (Character .forDigit ((b >> 4 ) & 0xF , 16 )));
595+ bb .put ((byte ) Character .toUpperCase (Character .forDigit (b & 0xF , 16 )));
596+ } else {
597+ bb .put (b );
583598 }
584599 }
585- return builder .toString ();
600+
601+ return new String (bb .array (), StandardCharsets .UTF_8 );
586602 }
587603
588604 /**
@@ -652,16 +668,16 @@ private void parse(final String purl) throws MalformedPackageURLException {
652668 // version is optional - check for existence
653669 index = remainder .lastIndexOf ('@' );
654670 if (index >= start ) {
655- this .version = validateVersion (percentDecode (remainder .substring (index + 1 )));
671+ this .version = validateVersion (uriDecode (remainder .substring (index + 1 )));
656672 remainder = remainder .substring (0 , index );
657673 }
658674
659675 // The 'remainder' should now consist of an optional namespace and the name
660676 index = remainder .lastIndexOf ('/' );
661677 if (index <= start ) {
662- this .name = validateName (percentDecode (remainder .substring (start )));
678+ this .name = validateName (uriDecode (remainder .substring (start )));
663679 } else {
664- this .name = validateName (percentDecode (remainder .substring (index + 1 )));
680+ this .name = validateName (uriDecode (remainder .substring (index + 1 )));
665681 remainder = remainder .substring (0 , index );
666682 this .namespace = validateNamespace (parsePath (remainder .substring (start ), false ));
667683 }
@@ -712,7 +728,7 @@ private Map<String, String> parseQualifiers(final String encodedString) throws M
712728 final String [] entry = value .split ("=" , 2 );
713729 if (entry .length == 2 && !entry [1 ].isEmpty ()) {
714730 String key = toLowerCase (entry [0 ]);
715- if (map .put (key , percentDecode (entry [1 ])) != null ) {
731+ if (map .put (key , uriDecode (entry [1 ])) != null ) {
716732 throw new ValidationException ("Duplicate package qualifier encountered. More then one value was specified for " + key );
717733 }
718734 }
@@ -731,7 +747,7 @@ private String[] parsePath(final String value, final boolean isSubpath) {
731747 }
732748 return PATH_SPLITTER .splitAsStream (value )
733749 .filter (segment -> !segment .isEmpty () && !(isSubpath && ("." .equals (segment ) || ".." .equals (segment ))))
734- .map (segment -> percentDecode (segment ))
750+ .map (segment -> uriDecode (segment ))
735751 .toArray (String []::new );
736752 }
737753
0 commit comments