21
21
*/
22
22
package com .github .packageurl ;
23
23
24
+ import java .io .ByteArrayOutputStream ;
24
25
import java .io .Serializable ;
25
26
import java .net .URI ;
26
27
import java .net .URISyntaxException ;
27
- import java .nio .charset . Charset ;
28
+ import java .nio .ByteBuffer ;
28
29
import java .nio .charset .StandardCharsets ;
29
30
import java .util .Arrays ;
30
31
import java .util .Collections ;
33
34
import java .util .TreeMap ;
34
35
import java .util .function .IntPredicate ;
35
36
import java .util .stream .Collectors ;
37
+ import java .util .stream .IntStream ;
36
38
37
39
/**
38
40
* <p>Package-URL (aka purl) is a "mostly universal" URL to describe a package. A purl is a URL composed of seven components:</p>
51
53
* @since 1.0.0
52
54
*/
53
55
public final class PackageURL implements Serializable {
54
-
55
56
private static final long serialVersionUID = 3243226021636427586L ;
56
57
58
+ private static final char PERCENT_CHAR = '%' ;
59
+
57
60
/**
58
61
* Constructs a new PackageURL object by parsing the specified string.
59
62
*
@@ -496,39 +499,14 @@ private String canonicalize(boolean coordinatesOnly) {
496
499
return purl .toString ();
497
500
}
498
501
499
- /**
500
- * Encodes the input in conformance with RFC 3986.
501
- *
502
- * @param input the String to encode
503
- * @return an encoded String
504
- */
505
- private String percentEncode (final String input ) {
506
- return uriEncode (input , StandardCharsets .UTF_8 );
507
- }
508
-
509
- private static String uriEncode (String source , Charset charset ) {
510
- if (source == null || source .isEmpty ()) {
511
- return source ;
512
- }
513
-
514
- StringBuilder builder = new StringBuilder ();
515
- for (byte b : source .getBytes (charset )) {
516
- if (isUnreserved (b )) {
517
- builder .append ((char ) b );
518
- }
519
- else {
520
- // Substitution: A '%' followed by the hexadecimal representation of the ASCII value of the replaced character
521
- builder .append ('%' );
522
- builder .append (Integer .toHexString (b ).toUpperCase ());
523
- }
524
- }
525
- return builder .toString ();
526
- }
527
-
528
502
private static boolean isUnreserved (int c ) {
529
503
return (isValidCharForKey (c ) || c == '~' );
530
504
}
531
505
506
+ private static boolean shouldEncode (int c ) {
507
+ return !isUnreserved (c );
508
+ }
509
+
532
510
private static boolean isAlpha (int c ) {
533
511
return (isLowerCase (c ) || isUpperCase (c ));
534
512
}
@@ -584,42 +562,93 @@ private static String toLowerCase(String s) {
584
562
return new String (chars );
585
563
}
586
564
587
- /**
588
- * Optionally decodes a String, if it's encoded. If String is not encoded,
589
- * method will return the original input value.
590
- *
591
- * @param input the value String to decode
592
- * @return a decoded String
593
- */
594
- private String percentDecode (final String input ) {
595
- if (input == null ) {
596
- return null ;
597
- }
598
- final String decoded = uriDecode (input );
599
- if (!decoded .equals (input )) {
600
- return decoded ;
565
+ private static String percentDecode (final String source ) {
566
+ if (source == null || source .isEmpty ()) {
567
+ return source ;
601
568
}
602
- return input ;
603
- }
604
569
605
- public static String uriDecode (String source ) {
606
- if (source == null ) {
570
+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
571
+ int percentCharCount = getPercentCharCount (bytes );
572
+
573
+ if (percentCharCount == 0 ) {
607
574
return source ;
608
575
}
609
- int length = source .length ();
610
- StringBuilder builder = new StringBuilder ();
576
+
577
+ int length = bytes .length ;
578
+ int capacity = (length + percentCharCount ) - (percentCharCount * 3 );
579
+
580
+ if (capacity <= 0 ) {
581
+ throw new ValidationException ("Invalid encoding in '" + source + "'" );
582
+ }
583
+
584
+ ByteBuffer buffer = ByteBuffer .allocate (capacity );
585
+
611
586
for (int i = 0 ; i < length ; i ++) {
612
- if (source .charAt (i ) == '%' ) {
613
- String str = source .substring (i + 1 , i + 3 );
614
- char c = (char ) Integer .parseInt (str , 16 );
615
- builder .append (c );
616
- i += 2 ;
587
+ if (buffer .position () + 1 > capacity ) {
588
+ throw new ValidationException ("Invalid encoding in '" + source + "'" );
589
+ }
590
+
591
+ int b ;
592
+
593
+ if (bytes [i ] == PERCENT_CHAR ) {
594
+ int b1 = Character .digit (bytes [++i ], 16 );
595
+ int b2 = Character .digit (bytes [++i ], 16 );
596
+ b = (byte ) ((b1 << 4 ) + b2 );
597
+ } else {
598
+ b = bytes [i ];
617
599
}
618
- else {
619
- builder .append (source .charAt (i ));
600
+
601
+ buffer .put ((byte ) b );
602
+ }
603
+
604
+ return new String (buffer .array (), StandardCharsets .UTF_8 );
605
+ }
606
+
607
+ @ Deprecated
608
+ public String uriDecode (final String source ) {
609
+ return percentDecode (source );
610
+ }
611
+
612
+ private static int getUnsafeCharCount (final byte [] bytes ) {
613
+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::shouldEncode ).count ();
614
+ }
615
+
616
+ private static boolean isPercent (int c ) {
617
+ return (c == PERCENT_CHAR );
618
+ }
619
+
620
+ private static int getPercentCharCount (final byte [] bytes ) {
621
+ return (int ) IntStream .range (0 , bytes .length ).map (i -> bytes [i ]).filter (PackageURL ::isPercent ).count ();
622
+ }
623
+
624
+ private static String percentEncode (final String source ) {
625
+ if (source == null || source .isEmpty ()) {
626
+ return source ;
627
+ }
628
+
629
+ byte [] bytes = source .getBytes (StandardCharsets .UTF_8 );
630
+ int unsafeCharCount = getUnsafeCharCount (bytes );
631
+
632
+ if (unsafeCharCount == 0 ) {
633
+ return source ;
634
+ }
635
+
636
+ int length = bytes .length ;
637
+ int capacity = (length - unsafeCharCount ) + (3 * unsafeCharCount );
638
+ ByteBuffer buffer = ByteBuffer .allocate (capacity );
639
+
640
+ for (byte b : bytes ) {
641
+ if (shouldEncode (b )) {
642
+ byte b1 = (byte ) Character .toUpperCase (Character .forDigit ((b >> 4 ) & 0xF , 16 ));
643
+ byte b2 = (byte ) Character .toUpperCase (Character .forDigit (b & 0xF , 16 ));
644
+ byte [] encoded = {(byte ) PERCENT_CHAR , b1 , b2 };
645
+ buffer .put (encoded , 0 , encoded .length );
646
+ } else {
647
+ buffer .put (b );
620
648
}
621
649
}
622
- return builder .toString ();
650
+
651
+ return new String (buffer .array (), StandardCharsets .UTF_8 );
623
652
}
624
653
625
654
/**
@@ -696,9 +725,9 @@ private void parse(final String purl) throws MalformedPackageURLException {
696
725
// The 'remainder' should now consist of an optional namespace and the name
697
726
index = remainder .lastIndexOf ('/' );
698
727
if (index <= start ) {
699
- this .name = validateName (percentDecode (remainder .substring (start )));
728
+ this .name = validateName (uriDecode (remainder .substring (start )));
700
729
} else {
701
- this .name = validateName (percentDecode (remainder .substring (index + 1 )));
730
+ this .name = validateName (uriDecode (remainder .substring (index + 1 )));
702
731
remainder = remainder .substring (0 , index );
703
732
this .namespace = validateNamespace (parsePath (remainder .substring (start ), false ));
704
733
}
@@ -749,7 +778,7 @@ private Map<String, String> parseQualifiers(final String encodedString) throws M
749
778
final String [] entry = value .split ("=" , 2 );
750
779
if (entry .length == 2 && !entry [1 ].isEmpty ()) {
751
780
String key = toLowerCase (entry [0 ]);
752
- if (map .put (key , percentDecode (entry [1 ])) != null ) {
781
+ if (map .put (key , uriDecode (entry [1 ])) != null ) {
753
782
throw new ValidationException ("Duplicate package qualifier encountered. More then one value was specified for " + key );
754
783
}
755
784
}
@@ -764,12 +793,12 @@ private Map<String, String> parseQualifiers(final String encodedString) throws M
764
793
private String [] parsePath (final String path , final boolean isSubpath ) {
765
794
return Arrays .stream (path .split ("/" ))
766
795
.filter (segment -> !segment .isEmpty () && !(isSubpath && ("." .equals (segment ) || ".." .equals (segment ))))
767
- .map (this ::percentDecode )
796
+ .map (PackageURL ::percentDecode )
768
797
.toArray (String []::new );
769
798
}
770
799
771
800
private String encodePath (final String path ) {
772
- return Arrays .stream (path .split ("/" )).map (this ::percentEncode ).collect (Collectors .joining ("/" ));
801
+ return Arrays .stream (path .split ("/" )).map (PackageURL ::percentEncode ).collect (Collectors .joining ("/" ));
773
802
}
774
803
775
804
/**
0 commit comments