View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.util;
16  
17  import static java.nio.charset.StandardCharsets.US_ASCII;
18  import static java.nio.charset.StandardCharsets.UTF_8;
19  
20  import java.io.ByteArrayOutputStream;
21  import java.net.MalformedURLException;
22  import java.net.URI;
23  import java.net.URISyntaxException;
24  import java.net.URL;
25  import java.net.URLEncoder;
26  import java.net.URLStreamHandler;
27  import java.nio.charset.Charset;
28  import java.util.BitSet;
29  import java.util.Locale;
30  import java.util.Objects;
31  
32  import org.htmlunit.WebAssert;
33  import org.htmlunit.protocol.AnyHandler;
34  import org.htmlunit.protocol.javascript.JavaScriptURLConnection;
35  
36  /**
37   * URL utilities class that makes it easy to create new URLs based off of old URLs
38   * without having to assemble or parse them yourself.
39   *
40   * @author Daniel Gredler
41   * @author Martin Tamme
42   * @author Sudhan Moghe
43   * @author Marc Guillemot
44   * @author Ahmed Ashour
45   * @author Ronald Brill
46   * @author Joerg Werner
47   * @author Hartmut Arlt
48   */
49  public final class UrlUtils {
50  
51      /** "about". */
52      public static final String ABOUT = "about";
53      /** "about:". */
54      public static final String ABOUT_SCHEME = ABOUT + ":";
55      /** "about:blank". */
56      public static final String ABOUT_BLANK = ABOUT_SCHEME + "blank";
57      /** URL for "about:blank". */
58      public static final URL URL_ABOUT_BLANK;
59  
60      private static final URLStreamHandler JS_HANDLER;
61      private static final URLStreamHandler ABOUT_HANDLER;
62      private static final URLStreamHandler DATA_HANDLER;
63  
64      private static final BitSet PATH_ALLOWED_CHARS = new BitSet(256);
65      private static final BitSet QUERY_ALLOWED_CHARS = new BitSet(256);
66      private static final BitSet ANCHOR_ALLOWED_CHARS = new BitSet(256);
67      private static final BitSet HASH_ALLOWED_CHARS = new BitSet(256);
68  
69      /*
70        URI allowed char initialization; based on HttpClient 3.1's URI bit sets.
71       */
72      static {
73          // make sure the handlers are available first (before calling toUrlSafe())
74          JS_HANDLER = new org.htmlunit.protocol.javascript.Handler();
75          ABOUT_HANDLER = new org.htmlunit.protocol.about.Handler();
76          DATA_HANDLER = new org.htmlunit.protocol.data.Handler();
77  
78          try {
79              URL_ABOUT_BLANK = new URL(null, ABOUT_BLANK, ABOUT_HANDLER);
80          }
81          catch (final MalformedURLException e) {
82              // should never happen
83              throw new RuntimeException(e);
84          }
85  
86          final BitSet reserved = new BitSet(256);
87          reserved.set(';');
88          reserved.set('/');
89          reserved.set('?');
90          reserved.set(':');
91          reserved.set('@');
92          reserved.set('&');
93          reserved.set('=');
94          reserved.set('+');
95          reserved.set('$');
96          reserved.set(',');
97  
98          final BitSet mark = new BitSet(256);
99          mark.set('-');
100         mark.set('_');
101         mark.set('.');
102         mark.set('!');
103         mark.set('~');
104         mark.set('*');
105         mark.set('\'');
106         mark.set('(');
107         mark.set(')');
108 
109         final BitSet alpha = new BitSet(256);
110         for (int i = 'a'; i <= 'z'; i++) {
111             alpha.set(i);
112         }
113         for (int i = 'A'; i <= 'Z'; i++) {
114             alpha.set(i);
115         }
116 
117         final BitSet digit = new BitSet(256);
118         for (int i = '0'; i <= '9'; i++) {
119             digit.set(i);
120         }
121 
122         final BitSet alphanumeric = new BitSet(256);
123         alphanumeric.or(alpha);
124         alphanumeric.or(digit);
125 
126         final BitSet unreserved = new BitSet(256);
127         unreserved.or(alphanumeric);
128         unreserved.or(mark);
129 
130         final BitSet hex = new BitSet(256);
131         hex.or(digit);
132         for (int i = 'a'; i <= 'f'; i++) {
133             hex.set(i);
134         }
135         for (int i = 'A'; i <= 'F'; i++) {
136             hex.set(i);
137         }
138 
139         final BitSet escaped = new BitSet(256);
140         escaped.set('%');
141         escaped.or(hex);
142 
143         final BitSet uric = new BitSet(256);
144         uric.or(reserved);
145         uric.or(unreserved);
146         uric.or(escaped);
147 
148         final BitSet pchar = new BitSet(256);
149         pchar.or(unreserved);
150         pchar.or(escaped);
151         pchar.set(':');
152         pchar.set('@');
153         pchar.set('&');
154         pchar.set('=');
155         pchar.set('+');
156         pchar.set('$');
157         pchar.set(',');
158 
159         final BitSet segment = new BitSet(256);
160         segment.or(pchar);
161         segment.set(';');
162         segment.or(pchar);
163 
164         final BitSet pathSegments = new BitSet(256);
165         pathSegments.set('/');
166         pathSegments.or(segment);
167 
168         final BitSet absPath = new BitSet(256);
169         absPath.set('/');
170         absPath.or(pathSegments);
171 
172         final BitSet allowedAbsPath = new BitSet(256);
173         allowedAbsPath.or(absPath);
174 
175         final BitSet allowedFragment = new BitSet(256);
176         allowedFragment.or(uric);
177 
178         final BitSet allowedQuery = new BitSet(256);
179         allowedQuery.or(uric);
180 
181         final BitSet allowedHash = new BitSet(256);
182         allowedHash.or(uric);
183 
184         PATH_ALLOWED_CHARS.or(allowedAbsPath);
185         QUERY_ALLOWED_CHARS.or(allowedQuery);
186         ANCHOR_ALLOWED_CHARS.or(allowedFragment);
187         HASH_ALLOWED_CHARS.or(allowedHash);
188     }
189 
190     /**
191      * Disallow instantiation of this class.
192      */
193     private UrlUtils() {
194         // Empty.
195     }
196 
197     /**
198      * <p>Constructs a URL instance based on the specified URL string, taking into account the fact that the
199      * specified URL string may represent an <code>"about:..."</code> URL, a <code>"javascript:..."</code> URL, or
200      * a <code>data:...</code> URL.</p>
201      *
202      * <p>The caller should be sure that URL strings passed to this method will parse correctly as URLs, as
203      * this method never expects to have to handle {@link MalformedURLException}s.</p>
204      *
205      * @param url the URL string to convert into a URL instance
206      * @return the constructed URL instance
207      */
208     public static URL toUrlSafe(final String url) {
209         try {
210             return toUrlUnsafe(url);
211         }
212         catch (final MalformedURLException e) {
213             // Should never happen.
214             throw new RuntimeException(e);
215         }
216     }
217 
218     /**
219      * <p>Constructs a URL instance based on the specified URL string, taking into account the fact that the
220      * specified URL string may represent an <code>"about:..."</code> URL, a <code>"javascript:..."</code> URL, or
221      * a <code>data:...</code> URL.</p>
222      *
223      * <p>Unlike {@link #toUrlSafe(String)}, the caller need not be sure that URL strings passed to this
224      * method will parse correctly as URLs.</p>
225      *
226      * @param url the URL string to convert into a URL instance
227      * @return the constructed URL instance
228      * @throws MalformedURLException if the URL string cannot be converted to a URL instance
229      */
230     public static URL toUrlUnsafe(final String url) throws MalformedURLException {
231         WebAssert.notNull("url", url);
232 
233         final String protocol = StringUtils.substringBefore(url, ":").toLowerCase(Locale.ROOT);
234 
235         if (protocol.isEmpty() || UrlUtils.isNormalUrlProtocol(protocol)) {
236             final URL response = new URL(url);
237             if (response.getProtocol().startsWith("http")
238                     && StringUtils.isEmptyOrNull(response.getHost())) {
239                 throw new MalformedURLException("Missing host name in url: " + url);
240             }
241             return response;
242         }
243 
244         if (JavaScriptURLConnection.JAVASCRIPT_PREFIX.equals(protocol + ":")) {
245             return new URL(null, url, JS_HANDLER);
246         }
247 
248         if (ABOUT.equals(protocol)) {
249             if (ABOUT_BLANK.equalsIgnoreCase(url)) {
250                 return URL_ABOUT_BLANK;
251             }
252             return new URL(null, url, ABOUT_HANDLER);
253         }
254 
255         if ("data".equals(protocol)) {
256             return new URL(null, url, DATA_HANDLER);
257         }
258 
259         return new URL(null, url, AnyHandler.INSTANCE);
260     }
261 
262     /**
263      * <p>Encodes illegal characters in the specified URL's path, query string and anchor according to the URL
264      * encoding rules observed in real browsers.</p>
265      *
266      * <p>For example, this method changes
267      * <code>"http://first/?a=b c"</code> to <code>"http://first/?a=b%20c"</code>.</p>
268      *
269      * @param url the URL to encode
270      * @param charset the charset
271      * @return the encoded URL
272      */
273     public static URL encodeUrl(final URL url, final Charset charset) {
274         if (!isNormalUrlProtocol(url.getProtocol())) {
275             return url; // javascript:, about:, data: and anything not supported like foo:
276         }
277 
278         try {
279             String path = url.getPath();
280             if (path != null) {
281                 path = encode(path, PATH_ALLOWED_CHARS, UTF_8);
282             }
283             String query = url.getQuery();
284             if (query != null) {
285                 query = encode(query, QUERY_ALLOWED_CHARS, charset);
286             }
287             String anchor = url.getRef();
288             if (anchor != null) {
289                 anchor = encode(anchor, ANCHOR_ALLOWED_CHARS, UTF_8);
290             }
291             return createNewUrl(url.getProtocol(), url.getUserInfo(), url.getHost(),
292                                 url.getPort(), path, anchor, query);
293         }
294         catch (final MalformedURLException e) {
295             // Impossible... I think.
296             throw new RuntimeException(e);
297         }
298     }
299 
300     /**
301      * Encodes and escapes the specified URI anchor string.
302      *
303      * @param anchor the anchor string to encode and escape
304      * @return the encoded and escaped anchor string
305      */
306     public static String encodeAnchor(final String anchor) {
307         if (anchor == null) {
308             return null;
309         }
310         return encode(anchor, ANCHOR_ALLOWED_CHARS, UTF_8);
311     }
312 
313     /**
314      * Encodes and escapes the specified URI hash string.
315      *
316      * @param hash the anchor string to encode and escape
317      * @return the encoded and escaped hash string
318      */
319     public static String encodeHash(final String hash) {
320         if (hash == null) {
321             return null;
322         }
323         return encode(hash, HASH_ALLOWED_CHARS, UTF_8);
324     }
325 
326     /**
327      * Encodes and escapes the specified URI hash string.
328      *
329      * @param query the query string to encode and escape
330      * @return the encoded and escaped hash string
331      */
332     public static String encodeQuery(final String query) {
333         if (query == null) {
334             return null;
335         }
336         return encode(query, QUERY_ALLOWED_CHARS, UTF_8);
337     }
338 
339     /**
340      * Unescapes and decodes the specified string.
341      *
342      * @param escaped the string to be unescaped and decoded
343      * @return the unescaped and decoded string
344      */
345     public static String decode(final String escaped) {
346         try {
347             final byte[] bytes = escaped.getBytes(US_ASCII);
348             final byte[] bytes2 = decodeUrl(bytes);
349             return new String(bytes2, UTF_8);
350         }
351         catch (final IllegalArgumentException e) {
352             // Should never happen.
353             throw new RuntimeException(e);
354         }
355     }
356 
357     /**
358      * Escapes and encodes the specified string. Based on HttpClient 3.1's <code>URIUtil.encode()</code> method.
359      *
360      * @param unescaped the string to encode
361      * @param allowed allowed characters that shouldn't be escaped
362      * @param charset the charset to use
363      * @return the escaped string
364      */
365     private static String encode(final String unescaped, final BitSet allowed, final Charset charset) {
366         final byte[] bytes = unescaped.getBytes(charset);
367         final byte[] bytes2 = encodeUrl(allowed, bytes);
368         return encodePercentSign(bytes2);
369     }
370 
371     /**
372      * Encodes every occurrence of the escape character '%' in the given input
373      * string that is not followed by two hexadecimal characters.
374      * @param input the input bytes
375      * @return the given input string where every occurrence of <code>%</code> in
376      *         invalid escape sequences has been replace by <code>%25</code>
377      */
378     private static String encodePercentSign(final byte[] input) {
379         if (input == null) {
380             return null;
381         }
382 
383         final StringBuilder result = new StringBuilder(new String(input, US_ASCII));
384         int state = -0;
385         int offset = 0;
386         for (int i = 0; i < input.length; i++) {
387             final byte b = input[i];
388             if (state == 0 && b == '%') {
389                 state = 1;
390             }
391             else if (state == 1 || state == 2) {
392                 if (('0' <= b && b <= '9')
393                         || ('A' <= b && b <= 'F')
394                         || ('a' <= b && b <= 'f')) {
395                     state++;
396                     if (state == 3) {
397                         state = 0;
398                     }
399                 }
400                 else {
401                     final int st = i - state + offset;
402                     result.replace(st, st + 1, "%25");
403                     offset = offset + 2;
404                     state = b == '%' ? 1 : 0;
405                 }
406             }
407         }
408         if (state == 1 || state == 2) {
409             final int st = input.length - state + offset;
410             result.replace(st, st + 1, "%25");
411         }
412         return result.toString();
413     }
414 
415     /**
416      * Creates and returns a new URL using only the protocol and authority from the given one.
417      * @param u the URL on which to base the returned URL
418      * @return a new URL using only the protocol and authority from the given one
419      * @throws MalformedURLException if there is a problem creating the new URL
420      */
421     public static URL getUrlWithoutPathRefQuery(final URL u) throws MalformedURLException {
422         return createNewUrl(u.getProtocol(), u.getAuthority(), null, null, null);
423     }
424 
425     /**
426      * Creates and returns a new URL using only the protocol, authority and path
427      * from the given one.
428      * @param u the URL on which to base the returned URL
429      * @return a new URL using only the protocol and authority from the given one
430      * @throws MalformedURLException if there is a problem creating the new URL
431      */
432     public static URL getUrlWithoutRef(final URL u) throws MalformedURLException {
433         return createNewUrl(u.getProtocol(), u.getAuthority(), u.getPath(), null, u.getQuery());
434     }
435 
436     /**
437      * Creates and returns a new URL identical to the specified URL, except using the specified protocol.
438      * @param u the URL on which to base the returned URL
439      * @param newProtocol the new protocol to use in the returned URL
440      * @return a new URL identical to the specified URL, except using the specified protocol
441      * @throws MalformedURLException if there is a problem creating the new URL
442      */
443     public static URL getUrlWithNewProtocol(final URL u, final String newProtocol) throws MalformedURLException {
444         return createNewUrl(newProtocol, u.getAuthority(), u.getPath(), u.getRef(), u.getQuery());
445     }
446 
447     /**
448      * Creates and returns a new URL identical to the specified URL, except using the specified host.
449      * @param u the URL on which to base the returned URL
450      * @param newHost the new host to use in the returned URL
451      * @return a new URL identical to the specified URL, except using the specified host
452      * @throws MalformedURLException if there is a problem creating the new URL
453      */
454     public static URL getUrlWithNewHost(final URL u, final String newHost)
455         throws MalformedURLException {
456         return createNewUrl(u.getProtocol(), u.getUserInfo(), newHost,
457                             u.getPort(), u.getPath(), u.getRef(), u.getQuery());
458     }
459 
460     /**
461      * Creates and returns a new URL identical to the specified URL, except using the specified host.
462      * @param u the URL on which to base the returned URL
463      * @param newHost the new host to use in the returned URL
464      * @param newPort the new port to use in the returned URL
465      * @return a new URL identical to the specified URL, except using the specified host
466      * @throws MalformedURLException if there is a problem creating the new URL
467      */
468     public static URL getUrlWithNewHostAndPort(final URL u, final String newHost, final int newPort)
469         throws MalformedURLException {
470         return createNewUrl(u.getProtocol(), u.getUserInfo(), newHost, newPort, u.getPath(), u.getRef(), u.getQuery());
471     }
472 
473     /**
474      * Creates and returns a new URL identical to the specified URL, except using the specified port.
475      * @param u the URL on which to base the returned URL
476      * @param newPort the new port to use in the returned URL or -1 to remove it
477      * @return a new URL identical to the specified URL, except using the specified port
478      * @throws MalformedURLException if there is a problem creating the new URL
479      */
480     public static URL getUrlWithNewPort(final URL u, final int newPort) throws MalformedURLException {
481         return createNewUrl(u.getProtocol(), u.getUserInfo(), u.getHost(),
482                             newPort, u.getPath(), u.getRef(), u.getQuery());
483     }
484 
485     /**
486      * Creates and returns a new URL identical to the specified URL, except using the specified path.
487      * @param u the URL on which to base the returned URL
488      * @param newPath the new path to use in the returned URL
489      * @return a new URL identical to the specified URL, except using the specified path
490      * @throws MalformedURLException if there is a problem creating the new URL
491      */
492     public static URL getUrlWithNewPath(final URL u, final String newPath) throws MalformedURLException {
493         return createNewUrl(u.getProtocol(), u.getAuthority(), newPath, u.getRef(), u.getQuery());
494     }
495 
496     /**
497      * Creates and returns a new URL identical to the specified URL, except using the specified reference.
498      * @param u the URL on which to base the returned URL
499      * @param newRef the new reference to use in the returned URL or null to remove it
500      * @return a new URL identical to the specified URL, except using the specified reference
501      * @throws MalformedURLException if there is a problem creating the new URL
502      */
503     public static URL getUrlWithNewRef(final URL u, final String newRef) throws MalformedURLException {
504         return createNewUrl(u.getProtocol(), u.getAuthority(), u.getPath(), newRef, u.getQuery());
505     }
506 
507     /**
508      * Creates and returns a new URL identical to the specified URL, except using the specified query string.
509      * @param u the URL on which to base the returned URL
510      * @param newQuery the new query string to use in the returned URL
511      * @return a new URL identical to the specified URL, except using the specified query string
512      * @throws MalformedURLException if there is a problem creating the new URL
513      */
514     public static URL getUrlWithNewQuery(final URL u, final String newQuery) throws MalformedURLException {
515         return createNewUrl(u.getProtocol(), u.getAuthority(), u.getPath(), u.getRef(), newQuery);
516     }
517 
518     /**
519      * Creates and returns a new URL identical to the specified URL, ignoring path, protocol and query.
520      * @param u the URL on which to base the returned URL
521      * @return a new URL identical to the specified URL, ignoring path, protocol and query
522      * @throws MalformedURLException if there is a problem creating the new URL
523      */
524     public static URL getUrlWithProtocolAndAuthority(final URL u) throws MalformedURLException {
525         return createNewUrl(u.getProtocol(), u.getAuthority(), null, null, null);
526     }
527 
528     /**
529      * Creates and returns a new URL identical to the specified URL but with a changed user name.
530      * @param u the URL on which to base the returned URL
531      * @param newUserName the new user name or null to remove it
532      * @return a new URL identical to the specified URL; only user name updated
533      * @throws MalformedURLException if there is a problem creating the new URL
534      */
535     public static URL getUrlWithNewUserName(final URL u, final String newUserName) throws MalformedURLException {
536         String newUserInfo = newUserName == null ? "" : newUserName;
537         final String userInfo = u.getUserInfo();
538         if (StringUtils.isNotBlank(userInfo)) {
539             final int colonIdx = userInfo.indexOf(':');
540             if (colonIdx > -1) {
541                 newUserInfo = newUserInfo + userInfo.substring(colonIdx);
542             }
543         }
544         return createNewUrl(u.getProtocol(), newUserInfo.isEmpty() ? null : newUserInfo,
545                 u.getHost(), u.getPort(), u.getPath(), u.getRef(), u.getQuery());
546     }
547 
548     /**
549      * Creates and returns a new URL identical to the specified URL but with a changed user password.
550      * @param u the URL on which to base the returned URL
551      * @param newUserPassword the new user password or null to remove it
552      * @return a new URL identical to the specified URL; only user name updated
553      * @throws MalformedURLException if there is a problem creating the new URL
554      */
555     public static URL getUrlWithNewUserPassword(final URL u, final String newUserPassword)
556             throws MalformedURLException {
557         String newUserInfo = newUserPassword == null ? "" : ':' + newUserPassword;
558         final String userInfo = u.getUserInfo();
559         if (StringUtils.isNotBlank(userInfo)) {
560             final int colonIdx = userInfo.indexOf(':');
561             if (colonIdx > -1) {
562                 newUserInfo = userInfo.substring(0, colonIdx) + newUserInfo;
563             }
564             else {
565                 newUserInfo = userInfo + newUserInfo;
566             }
567         }
568         return createNewUrl(u.getProtocol(), newUserInfo.isEmpty() ? null : newUserInfo,
569                 u.getHost(), u.getPort(), u.getPath(), u.getRef(), u.getQuery());
570     }
571 
572     /**
573      * Creates a new URL based on the specified fragments.
574      * @param protocol the protocol to use (may not be {@code null})
575      * @param userInfo the user info to use (may be {@code null})
576      * @param host the host to use (may not be {@code null})
577      * @param port the port to use (may be <code>-1</code> if no port is specified)
578      * @param path the path to use (may be {@code null} and may omit the initial <code>'/'</code>)
579      * @param ref the reference to use (may be {@code null} and must not include the <code>'#'</code>)
580      * @param query the query to use (may be {@code null} and must not include the <code>'?'</code>)
581      * @return a new URL based on the specified fragments
582      * @throws MalformedURLException if there is a problem creating the new URL
583      */
584     private static URL createNewUrl(final String protocol, final String userInfo, final String host, final int port,
585             final String path, final String ref, final String query) throws MalformedURLException {
586         final StringBuilder s = new StringBuilder();
587         s.append(protocol).append("://");
588         if (userInfo != null) {
589             s.append(userInfo).append('@');
590         }
591         s.append(host);
592         if (port != -1) {
593             s.append(':').append(port);
594         }
595         if (path != null && !path.isEmpty()) {
596             if ('/' != path.charAt(0)) {
597                 s.append('/');
598             }
599             s.append(path);
600         }
601         if (query != null) {
602             s.append('?').append(query);
603         }
604         if (ref != null) {
605             if (ref.isEmpty() || ref.charAt(0) != '#') {
606                 s.append('#');
607             }
608             s.append(ref);
609         }
610 
611         return new URL(s.toString());
612     }
613 
614     /**
615      * Creates a new URL based on the specified fragments.
616      * @param protocol the protocol to use (may not be {@code null})
617      * @param authority the authority to use (may not be {@code null})
618      * @param path the path to use (may be {@code null} and may omit the initial <code>'/'</code>)
619      * @param ref the reference to use (may be {@code null} and must not include the <code>'#'</code>)
620      * @param query the query to use (may be {@code null} and must not include the <code>'?'</code>)
621      * @return a new URL based on the specified fragments
622      * @throws MalformedURLException if there is a problem creating the new URL
623      */
624     private static URL createNewUrl(final String protocol, final String authority,
625             final String path, final String ref, final String query) throws MalformedURLException {
626 
627         // pre-compute length of StringBuilder
628         int len = protocol.length() + 1;
629         if (authority != null && !authority.isEmpty()) {
630             len += 2 + authority.length();
631         }
632         if (path != null) {
633             len += path.length();
634         }
635         if (query != null) {
636             len += 1 + query.length();
637         }
638         if (ref != null) {
639             len += 1 + ref.length();
640         }
641 
642         final StringBuilder s = new StringBuilder(len);
643         s.append(protocol).append(':');
644         if (authority != null && !authority.isEmpty()) {
645             s.append("//").append(authority);
646         }
647         if (path != null) {
648             s.append(path);
649         }
650         if (query != null) {
651             s.append('?').append(query);
652         }
653         if (ref != null) {
654             if (ref.isEmpty() || ref.charAt(0) != '#') {
655                 s.append('#');
656             }
657             s.append(ref);
658         }
659 
660         return toUrlSafe(s.toString());
661     }
662 
663     /**
664      * Resolves a given relative URL against a base URL. See
665      * <a href="http://www.faqs.org/rfcs/rfc1808.html">RFC1808</a>
666      * Section 4 for more details.
667      *
668      * @param baseUrl     The base URL in which to resolve the specification.
669      * @param relativeUrl The relative URL to resolve against the base URL.
670      * @return the resolved specification.
671      */
672     public static String resolveUrl(final String baseUrl, final String relativeUrl) {
673         if (baseUrl == null) {
674             throw new IllegalArgumentException("Base URL must not be null");
675         }
676         if (relativeUrl == null) {
677             throw new IllegalArgumentException("Relative URL must not be null");
678         }
679         final Url url = resolveUrl(parseUrl(baseUrl), relativeUrl);
680 
681         return url.toString();
682     }
683 
684     /**
685      * Resolves a given relative URL against a base URL. See
686      * <a href="http://www.faqs.org/rfcs/rfc1808.html">RFC1808</a>
687      * Section 4 for more details.
688      *
689      * @param baseUrl     The base URL in which to resolve the specification.
690      * @param relativeUrl The relative URL to resolve against the base URL.
691      * @return the resolved specification.
692      */
693     public static String resolveUrl(final URL baseUrl, final String relativeUrl) {
694         if (baseUrl == null) {
695             throw new IllegalArgumentException("Base URL must not be null");
696         }
697         return resolveUrl(baseUrl.toExternalForm(), relativeUrl);
698     }
699 
700     /**
701      * Parses a given specification using the algorithm depicted in
702      * <a href="http://www.faqs.org/rfcs/rfc1808.html">RFC1808</a>.
703      * <p>
704      * Section 2.4: Parsing a URL
705      * </p>
706      * <p>
707      *   An accepted method for parsing URLs is useful to clarify the
708      *   generic-RL syntax of Section 2.2 and to describe the algorithm for
709      *   resolving relative URLs presented in Section 4. This section
710      *   describes the parsing rules for breaking down a URL (relative or
711      *   absolute) into the component parts described in Section 2.1.  The
712      *   rules assume that the URL has already been separated from any
713      *   surrounding text and copied to a "parse string". The rules are
714      *   listed in the order in which they would be applied by the parser.
715      * </p>
716      * @param spec The specification to parse.
717      * @return the parsed specification.
718      */
719     private static Url parseUrl(String spec) {
720         final Url url = new Url();
721         int startIndex = 0;
722         int endIndex = spec.length();
723 
724         // see https://url.spec.whatwg.org/#concept-basic-url-parser
725         //   * If input contains any leading or trailing C0 control or space, validation error.
726         //     Remove any leading and trailing C0 control or space from input.
727         //   * If input contains any ASCII tab or newline, validation error.
728         //     Remove all ASCII tab or newline from input.
729 
730         if (endIndex > startIndex) {
731             StringBuilder sb = null;
732             boolean before = true;
733             int trailing = 0;
734 
735             for (int i = 0; i < endIndex; i++) {
736                 final char c = spec.charAt(i);
737                 boolean remove = false;
738 
739                 if (c == '\t' | c == '\r' | c == '\n') {
740                     remove = true;
741                 }
742                 else if ('\u0000' <= c && c <= '\u0020') {
743                     if (before) {
744                         remove = true;
745                     }
746                     else {
747                         trailing++;
748                     }
749                 }
750                 else {
751                     before = false;
752                     trailing = 0;
753                 }
754 
755                 if (remove) {
756                     if (sb == null) {
757                         sb = new StringBuilder(spec.substring(0, i));
758                     }
759                 }
760                 else if (sb != null) {
761                     sb.append(c);
762                 }
763             }
764 
765             if (sb == null) {
766                 if (trailing > 0) {
767                     endIndex = spec.length() - trailing;
768                     spec = spec.substring(0, endIndex);
769                 }
770             }
771             else {
772                 if (trailing > 0) {
773                     spec = sb.substring(0, sb.length() - trailing);
774                 }
775                 else {
776                     spec = sb.toString();
777                 }
778                 endIndex = spec.length();
779             }
780         }
781 
782         // Section 2.4.1: Parsing the Fragment Identifier
783         //
784         //   If the parse string contains a crosshatch "#" character, then the
785         //   substring after the first (left-most) crosshatch "#" and up to the
786         //   end of the parse string is the <fragment> identifier. If the
787         //   crosshatch is the last character, or no crosshatch is present, then
788         //   the fragment identifier is empty. The matched substring, including
789         //   the crosshatch character, is removed from the parse string before
790         //   continuing.
791         //
792         //   Note that the fragment identifier is not considered part of the URL.
793         //   However, since it is often attached to the URL, parsers must be able
794         //   to recognize and set aside fragment identifiers as part of the
795         //   process.
796         final int crosshatchIndex = StringUtils.indexOf(spec, '#', startIndex, endIndex);
797 
798         if (crosshatchIndex >= 0) {
799             url.fragment_ = spec.substring(crosshatchIndex + 1, endIndex);
800             endIndex = crosshatchIndex;
801         }
802         // Section 2.4.2: Parsing the Scheme
803         //
804         //   If the parse string contains a colon ":" after the first character
805         //   and before any characters not allowed as part of a scheme name (i.e.,
806         //   any not an alphanumeric, plus "+", period ".", or hyphen "-"), the
807         //   <scheme> of the URL is the substring of characters up to but not
808         //   including the first colon. These characters and the colon are then
809         //   removed from the parse string before continuing.
810         final int colonIndex = StringUtils.indexOf(spec, ':', startIndex, endIndex);
811 
812         if (colonIndex > 0) {
813             final String scheme = spec.substring(startIndex, colonIndex);
814             if (isValidScheme(scheme)) {
815                 url.scheme_ = scheme;
816                 startIndex = colonIndex + 1;
817             }
818         }
819         // Section 2.4.3: Parsing the Network Location/Login
820         //
821         //   If the parse string begins with a double-slash "//", then the
822         //   substring of characters after the double-slash and up to, but not
823         //   including, the next slash "/" character is the network location/login
824         //   (<net_loc>) of the URL. If no trailing slash "/" is present, the
825         //   entire remaining parse string is assigned to <net_loc>. The double-
826         //   slash and <net_loc> are removed from the parse string before
827         //   continuing.
828         //
829         // Note: We also accept a question mark "?" or a semicolon ";" character as
830         //       delimiters for the network location/login (<net_loc>) of the URL.
831         final int locationStartIndex;
832         int locationEndIndex;
833 
834         if (spec.startsWith("//", startIndex)) {
835             locationStartIndex = startIndex + 2;
836             locationEndIndex = StringUtils.indexOf(spec, '/', locationStartIndex, endIndex);
837             if (locationEndIndex >= 0) {
838                 startIndex = locationEndIndex;
839             }
840         }
841         else {
842             locationStartIndex = -1;
843             locationEndIndex = -1;
844         }
845         // Section 2.4.4: Parsing the Query Information
846         //
847         //   If the parse string contains a question mark "?" character, then the
848         //   substring after the first (left-most) question mark "?" and up to the
849         //   end of the parse string is the <query> information. If the question
850         //   mark is the last character, or no question mark is present, then the
851         //   query information is empty. The matched substring, including the
852         //   question mark character, is removed from the parse string before
853         //   continuing.
854         final int questionMarkIndex = StringUtils.indexOf(spec, '?', startIndex, endIndex);
855 
856         if (questionMarkIndex >= 0) {
857             if ((locationStartIndex >= 0) && (locationEndIndex < 0)) {
858                 // The substring of characters after the double-slash and up to, but not
859                 // including, the question mark "?" character is the network location/login
860                 // (<net_loc>) of the URL.
861                 locationEndIndex = questionMarkIndex;
862                 startIndex = questionMarkIndex;
863             }
864             url.query_ = spec.substring(questionMarkIndex + 1, endIndex);
865             endIndex = questionMarkIndex;
866         }
867         // Section 2.4.5: Parsing the Parameters
868         //
869         //   If the parse string contains a semicolon ";" character, then the
870         //   substring after the first (left-most) semicolon ";" and up to the end
871         //   of the parse string is the parameters (<params>). If the semicolon
872         //   is the last character, or no semicolon is present, then <params> is
873         //   empty. The matched substring, including the semicolon character, is
874         //   removed from the parse string before continuing.
875         final int semicolonIndex = StringUtils.indexOf(spec, ';', startIndex, endIndex);
876 
877         if (semicolonIndex >= 0) {
878             if ((locationStartIndex >= 0) && (locationEndIndex < 0)) {
879                 // The substring of characters after the double-slash and up to, but not
880                 // including, the semicolon ";" character is the network location/login
881                 // (<net_loc>) of the URL.
882                 locationEndIndex = semicolonIndex;
883                 startIndex = semicolonIndex;
884             }
885             url.parameters_ = spec.substring(semicolonIndex + 1, endIndex);
886             endIndex = semicolonIndex;
887         }
888         // Section 2.4.6: Parsing the Path
889         //
890         //   After the above steps, all that is left of the parse string is the
891         //   URL <path> and the slash "/" that may precede it. Even though the
892         //   initial slash is not part of the URL path, the parser must remember
893         //   whether or not it was present so that later processes can
894         //   differentiate between relative and absolute paths. Often this is
895         //   done by simply storing the preceding slash along with the path.
896         if ((locationStartIndex >= 0) && (locationEndIndex < 0)) {
897             // The entire remaining parse string is assigned to the network
898             // location/login (<net_loc>) of the URL.
899             locationEndIndex = endIndex;
900         }
901         else if (startIndex < endIndex) {
902             url.path_ = spec.substring(startIndex, endIndex);
903         }
904         // Set the network location/login (<net_loc>) of the URL.
905         if ((locationStartIndex >= 0) && (locationEndIndex >= 0)) {
906             url.location_ = spec.substring(locationStartIndex, locationEndIndex);
907         }
908         return url;
909     }
910 
911     /**
912      * Returns true if specified string is a valid scheme name.
913      * <p>
914      * https://tools.ietf.org/html/rfc1738
915      * </p>
916      * <p>
917      * Scheme names consist of a sequence of characters. The lower case
918      * letters "a"--"z", digits, and the characters plus ("+"), period
919      * ("."), and hyphen ("-") are allowed. For resiliency, programs
920      * interpreting URLs should treat upper case letters as equivalent to
921      * lower case in scheme names (e.g., allow "HTTP" as well as "http").
922      * </p>
923      *
924      * @param scheme the scheme string to check
925      * @return true if valid
926      */
927     public static boolean isValidScheme(final String scheme) {
928         final int length = scheme.length();
929         if (length < 1) {
930             return false;
931         }
932 
933         char c = scheme.charAt(0);
934         boolean isValid = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z');
935         if (!isValid) {
936             return false;
937         }
938 
939         for (int i = 1; i < length; i++) {
940             c = scheme.charAt(i);
941             isValid =
942                     ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
943                     || ('0' <= c && c <= '9')
944                     || c == '+'
945                     || c == '.'
946                     || c == '-';
947             if (!isValid) {
948                 return false;
949             }
950         }
951 
952         return true;
953     }
954 
955     /**
956      * Returns true if specified string is a special scheme.
957      * see <a href='https://url.spec.whatwg.org/#special-scheme'>
958      * https://url.spec.whatwg.org/#special-scheme</a>
959      *
960      * @param scheme the scheme string to check
961      * @return true if special
962      */
963     public static boolean isSpecialScheme(final String scheme) {
964         final int length = scheme.length();
965         if (length < 2 || length > 5) {
966             return false;
967         }
968 
969         final String schemeLC = scheme.toLowerCase(Locale.ROOT);
970         return "ftp".equals(schemeLC)
971                 || "file".equals(schemeLC)
972                 || "http".equals(schemeLC)
973                 || "https".equals(schemeLC)
974                 || "ws".equals(schemeLC)
975                 || "wss".equals(schemeLC);
976     }
977 
978     /**
979      * Resolves a given relative URL against a base URL using the algorithm
980      * depicted in <a href="http://www.faqs.org/rfcs/rfc1808.html">RFC1808</a>.
981      * <p>
982      * Section 4: Resolving Relative URLs
983      * </p>
984      * <p>
985      *   This section describes an example algorithm for resolving URLs within
986      *   a context in which the URLs may be relative, such that the result is
987      *   always a URL in absolute form. Although this algorithm cannot
988      *   guarantee that the resulting URL will equal that intended by the
989      *   original author, it does guarantee that any valid URL (relative or
990      *   absolute) can be consistently transformed to an absolute form given a
991      *   valid base URL.
992      * </p>
993      *
994      * @param baseUrl     The base URL in which to resolve the specification.
995      * @param relativeUrl The relative URL to resolve against the base URL.
996      * @return the resolved specification.
997      */
998     private static Url resolveUrl(final Url baseUrl, final String relativeUrl) {
999         final Url url = parseUrl(relativeUrl);
1000         // Step 1: The base URL is established according to the rules of
1001         //         Section 3.  If the base URL is the empty string (unknown),
1002         //         the embedded URL is interpreted as an absolute URL and
1003         //         we are done.
1004         if (baseUrl == null) {
1005             return url;
1006         }
1007         // Step 2: Both the base and embedded URLs are parsed into their
1008         //         component parts as described in Section 2.4.
1009         //      a) If the embedded URL is entirely empty, it inherits the
1010         //         entire base URL (i.e., is set equal to the base URL)
1011         //         and we are done.
1012         if (relativeUrl.isEmpty()) {
1013             return new Url(baseUrl);
1014         }
1015         //      b) If the embedded URL starts with a scheme name, it is
1016         //         interpreted as an absolute URL and we are done.
1017         if (url.scheme_ != null) {
1018             return url;
1019         }
1020         //      c) Otherwise, the embedded URL inherits the scheme of
1021         //         the base URL.
1022         url.scheme_ = baseUrl.scheme_;
1023         // Step 3: If the embedded URL's <net_loc> is non-empty, we skip to
1024         //         Step 7.  Otherwise, the embedded URL inherits the <net_loc>
1025         //         (if any) of the base URL.
1026         if (url.location_ != null) {
1027             return url;
1028         }
1029         url.location_ = baseUrl.location_;
1030         // Step 4: If the embedded URL path is preceded by a slash "/", the
1031         //         path is not relative and we skip to Step 7.
1032         if (url.path_ != null && !url.path_.isEmpty() && url.path_.charAt(0) == '/') {
1033             url.path_ = removeLeadingSlashPoints(url.path_);
1034             return url;
1035         }
1036         // Step 5: If the embedded URL path is empty (and not preceded by a
1037         //         slash), then the embedded URL inherits the base URL path,
1038         //         and
1039         if (url.path_ == null) {
1040             url.path_ = baseUrl.path_;
1041             //  a) if the embedded URL's <params> is non-empty, we skip to
1042             //     step 7; otherwise, it inherits the <params> of the base
1043             //     URL (if any) and
1044             if (url.parameters_ != null) {
1045                 return url;
1046             }
1047             url.parameters_ = baseUrl.parameters_;
1048             //  b) if the embedded URL's <query> is non-empty, we skip to
1049             //     step 7; otherwise, it inherits the <query> of the base
1050             //     URL (if any) and we skip to step 7.
1051             if (url.query_ != null) {
1052                 return url;
1053             }
1054             url.query_ = baseUrl.query_;
1055             return url;
1056         }
1057         // Step 6: The last segment of the base URL's path (anything
1058         //         following the rightmost slash "/", or the entire path if no
1059         //         slash is present) is removed and the embedded URL's path is
1060         //         appended in its place.  The following operations are
1061         //         then applied, in order, to the new path:
1062         final String basePath = baseUrl.path_;
1063         String path = "";
1064 
1065         if (basePath == null) {
1066             path = "/";
1067         }
1068         else {
1069             final int lastSlashIndex = basePath.lastIndexOf('/');
1070 
1071             if (lastSlashIndex >= 0) {
1072                 path = basePath.substring(0, lastSlashIndex + 1);
1073             }
1074         }
1075 
1076         path = path.concat(url.path_);
1077         //      a) All occurrences of "./", where "." is a complete path
1078         //         segment, are removed.
1079         int pathSegmentIndex;
1080 
1081         while ((pathSegmentIndex = path.indexOf("/./")) >= 0) {
1082             path = path.substring(0, pathSegmentIndex + 1).concat(path.substring(pathSegmentIndex + 3));
1083         }
1084         //      b) If the path ends with "." as a complete path segment,
1085         //         that "." is removed.
1086         if (path.endsWith("/.")) {
1087             path = path.substring(0, path.length() - 1);
1088         }
1089         //      c) All occurrences of "<segment>/../", where <segment> is a
1090         //         complete path segment not equal to "..", are removed.
1091         //         Removal of these path segments is performed iteratively,
1092         //         removing the leftmost matching pattern on each iteration,
1093         //         until no matching pattern remains.
1094         while ((pathSegmentIndex = path.indexOf("/../")) > 0) {
1095             final String pathSegment = path.substring(0, pathSegmentIndex);
1096             final int slashIndex = pathSegment.lastIndexOf('/');
1097 
1098             if (slashIndex >= 0) {
1099                 if (!"..".equals(pathSegment.substring(slashIndex))) {
1100                     path = path.substring(0, slashIndex + 1).concat(path.substring(pathSegmentIndex + 4));
1101                 }
1102             }
1103             else {
1104                 path = path.substring(pathSegmentIndex + 4);
1105             }
1106         }
1107         //      d) If the path ends with "<segment>/..", where <segment> is a
1108         //         complete path segment not equal to "..", that
1109         //         "<segment>/.." is removed.
1110         if (path.endsWith("/..")) {
1111             final String pathSegment = path.substring(0, path.length() - 3);
1112             final int slashIndex = pathSegment.lastIndexOf('/');
1113 
1114             if (slashIndex >= 0) {
1115                 path = path.substring(0, slashIndex + 1);
1116             }
1117         }
1118 
1119         path = removeLeadingSlashPoints(path);
1120 
1121         url.path_ = path;
1122         // Step 7: The resulting URL components, including any inherited from
1123         //         the base URL, are recombined to give the absolute form of
1124         //         the embedded URL.
1125         return url;
1126     }
1127 
1128     /**
1129      * "../" after the leading "/" should be removed as browsers do (not in RFC).
1130      */
1131     private static String removeLeadingSlashPoints(final String path) {
1132         int i = 1;
1133         while (path.startsWith("../", i)) {
1134             i = i + 3;
1135         }
1136 
1137         if (i > 1) {
1138             return "/" + path.substring(i);
1139         }
1140 
1141         return path;
1142     }
1143 
1144     /**
1145      * Class <code>Url</code> represents a Uniform Resource Locator.
1146      */
1147     private static class Url {
1148 
1149         private String scheme_;
1150         private String location_;
1151         private String path_;
1152         private String parameters_;
1153         private String query_;
1154         private String fragment_;
1155 
1156         /**
1157          * Creates a <code>Url</code> object.
1158          */
1159         Url() {
1160             super();
1161         }
1162 
1163         /**
1164          * Creates a <code>Url</code> object from the specified
1165          * <code>Url</code> object.
1166          *
1167          * @param url a <code>Url</code> object.
1168          */
1169         Url(final Url url) {
1170             scheme_ = url.scheme_;
1171             location_ = url.location_;
1172             path_ = url.path_;
1173             parameters_ = url.parameters_;
1174             query_ = url.query_;
1175             fragment_ = url.fragment_;
1176         }
1177 
1178         /**
1179          * Returns a string representation of the <code>Url</code> object.
1180          *
1181          * @return a string representation of the <code>Url</code> object.
1182          */
1183         @Override
1184         public String toString() {
1185             final StringBuilder sb = new StringBuilder();
1186 
1187             if (scheme_ != null) {
1188                 sb.append(scheme_).append(':');
1189             }
1190             if (location_ != null) {
1191                 sb.append("//").append(location_);
1192             }
1193             if (path_ != null) {
1194                 sb.append(path_);
1195             }
1196             if (parameters_ != null) {
1197                 sb.append(';').append(parameters_);
1198             }
1199             if (query_ != null) {
1200                 sb.append('?').append(query_);
1201             }
1202             if (fragment_ != null) {
1203                 sb.append('#').append(fragment_);
1204             }
1205             return sb.toString();
1206         }
1207     }
1208 
1209     static boolean isNormalUrlProtocol(final String protocol) {
1210         return "http".equals(protocol) || "https".equals(protocol) || "file".equals(protocol);
1211     }
1212 
1213     /**
1214      * More or less the same as sameFile(URL, URL) but without
1215      * resolving the host to an IP address for comparing.
1216      * Additionally we do some path normalization.
1217      *
1218      * @param u1 a URL object
1219      * @param u2 a URL object
1220      * @return true if u1 and u2 refer to the same file
1221      */
1222     public static boolean sameFile(final URL u1, final URL u2) {
1223         if (u1 == u2) {
1224             return true;
1225         }
1226         if (u1 == null || u2 == null) {
1227             return false;
1228         }
1229 
1230         // Compare the protocols.
1231         final String p1 = u1.getProtocol();
1232         final String p2 = u2.getProtocol();
1233         if (!(p1 == p2 || (p1 != null && p1.equalsIgnoreCase(p2)))) {
1234             return false;
1235         }
1236 
1237         // Compare the ports.
1238         final int port1 = (u1.getPort() == -1) ? u1.getDefaultPort() : u1.getPort();
1239         final int port2 = (u2.getPort() == -1) ? u2.getDefaultPort() : u2.getPort();
1240         if (port1 != port2) {
1241             return false;
1242         }
1243 
1244         // Compare the hosts.
1245         final String h1 = u1.getHost();
1246         final String h2 = u2.getHost();
1247         if (!(h1 == h2 || (h1 != null && h1.equalsIgnoreCase(h2)))) {
1248             return false;
1249         }
1250 
1251         // Compare the files.
1252         String f1 = u1.getFile();
1253         if (f1.isEmpty()) {
1254             f1 = "/";
1255         }
1256         String f2 = u2.getFile();
1257         if (f2.isEmpty()) {
1258             f2 = "/";
1259         }
1260         if (f1.indexOf('.') > 0 || f2.indexOf('.') > 0) {
1261             try {
1262                 f1 = u1.toURI().normalize().toURL().getFile();
1263                 f2 = u2.toURI().normalize().toURL().getFile();
1264             }
1265             catch (final RuntimeException e) {
1266                 throw e;
1267             }
1268             catch (final Exception ignored) {
1269                 // ignore
1270             }
1271         }
1272 
1273         return Objects.equals(f1, f2);
1274     }
1275 
1276     /**
1277      * Helper that constructs a normalized url string
1278      * usable as cache key.
1279      *
1280      * @param url a URL object
1281      * @return the normalized string
1282      */
1283     public static String normalize(final URL url) {
1284         final StringBuilder result = new StringBuilder();
1285         result.append(url.getProtocol())
1286                 .append("://")
1287                 .append(url.getHost())
1288                 .append(':')
1289                 .append((url.getPort() == -1) ? url.getDefaultPort() : url.getPort());
1290 
1291         // Compare the files.
1292         String f = url.getFile();
1293         if (f.isEmpty()) {
1294             result.append('/');
1295         }
1296         else {
1297             if (f.indexOf('.') > 0) {
1298                 try {
1299                     f = url.toURI().normalize().toURL().getFile();
1300                 }
1301                 catch (final Exception ignored) {
1302                     // ignore
1303                 }
1304             }
1305             result.append(f);
1306         }
1307 
1308         return result.toString();
1309     }
1310 
1311     /**
1312      * Constructs a {@link URI} using the specified URL.
1313      *
1314      * @param url the URL
1315      * @param query the query
1316      *
1317      * @throws URISyntaxException
1318      *         If both a scheme and a path are given but the path is
1319      *         relative, if the URI string constructed from the given
1320      *         components violates RFC&nbsp;2396, or if the authority
1321      *         component of the string is present but cannot be parsed
1322      *         as a server-based authority
1323      * @return the URI
1324      */
1325     public static URI toURI(final URL url, final String query) throws URISyntaxException {
1326         final String scheme = url.getProtocol();
1327         final String host = url.getHost();
1328         final int port = url.getPort();
1329         final String path = url.getPath();
1330         final StringBuilder buffer = new StringBuilder();
1331         if (host != null) {
1332             if (scheme != null) {
1333                 buffer.append(scheme).append("://");
1334             }
1335             buffer.append(host);
1336             if (port > 0) {
1337                 buffer.append(':').append(port);
1338             }
1339         }
1340         if (path == null || path.isEmpty() || path.charAt(0) != '/') {
1341             buffer.append('/');
1342         }
1343         if (path != null) {
1344             buffer.append(path);
1345         }
1346         if (query != null) {
1347             buffer.append('?').append(query);
1348         }
1349         return new URI(buffer.toString());
1350     }
1351 
1352     /**
1353      * Returns the encoded string.
1354      *
1355      * @param part the part to encode
1356      * @return the encoded string
1357      */
1358     public static String encodeQueryPart(final String part) {
1359         if (part == null || part.isEmpty()) {
1360             return "";
1361         }
1362 
1363         return URLEncoder.encode(part, UTF_8);
1364     }
1365 
1366     /**
1367      * Removes the well known ports if it can be deduced from protocol.
1368      * @param url the url to clean up
1369      * @return a new URL without the port or the given one
1370      * @throws MalformedURLException if the URL string cannot be converted to a URL instance
1371      */
1372     public static URL removeRedundantPort(final URL url) throws MalformedURLException {
1373         if (("https".equals(url.getProtocol()) && url.getPort() == 443)
1374                 || ("http".equals(url.getProtocol()) && url.getPort() == 80)) {
1375             return getUrlWithNewPort(url, -1);
1376         }
1377         return url;
1378     }
1379 
1380     /**
1381      * Decodes an array of URL safe 7-bit characters into an array of original bytes.
1382      * Escaped characters are converted back to their original representation.
1383      * @param bytes array of URL safe characters
1384      * @param removeWhitespace if true don't add whitespace chars to the output
1385      * @return array of original bytes
1386      * @throws IllegalArgumentException in case of error
1387      */
1388     public static byte[] decodeDataUrl(final byte[] bytes, final boolean removeWhitespace)
1389                             throws IllegalArgumentException  {
1390         // adapted from apache commons codec
1391         if (bytes == null) {
1392             return null;
1393         }
1394         final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
1395         for (int i = 0; i < bytes.length; i++) {
1396             int b = bytes[i];
1397             if (b == '%') {
1398                 try {
1399                     final int u = digit16(bytes[++i]);
1400                     final int l = digit16(bytes[++i]);
1401                     b = (u << 4) + l;
1402                 }
1403                 catch (final ArrayIndexOutOfBoundsException e) {
1404                     throw new IllegalArgumentException("Invalid URL encoding: ", e);
1405                 }
1406             }
1407             if (removeWhitespace
1408                     && (b == 9 || b == 10 || b == 12 || b == 13 || b == 32)) {
1409                 continue;
1410             }
1411 
1412             buffer.write(b);
1413         }
1414         return buffer.toByteArray();
1415     }
1416 
1417     /**
1418      * Decodes an array of URL safe 7-bit characters into an array of original bytes.
1419      * Escaped characters are converted back to their original representation.
1420      * @param bytes array of URL safe characters
1421      * @return array of original bytes
1422      * @throws IllegalArgumentException in case of error
1423      */
1424     public static byte[] decodeUrl(final byte[] bytes) throws IllegalArgumentException {
1425         // adapted from apache commons codec
1426         if (bytes == null) {
1427             return null;
1428         }
1429         final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
1430         for (int i = 0; i < bytes.length; i++) {
1431             final int b = bytes[i];
1432             if (b == '+') {
1433                 buffer.write(' ');
1434             }
1435             else if (b == '%') {
1436                 try {
1437                     final int u = digit16(bytes[++i]);
1438                     final int l = digit16(bytes[++i]);
1439                     buffer.write((char) ((u << 4) + l));
1440                 }
1441                 catch (final ArrayIndexOutOfBoundsException e) {
1442                     throw new IllegalArgumentException("Invalid URL encoding: ", e);
1443                 }
1444             }
1445             else {
1446                 buffer.write(b);
1447             }
1448         }
1449         return buffer.toByteArray();
1450     }
1451 
1452     private static int digit16(final byte b) throws IllegalArgumentException  {
1453         final int i = Character.digit((char) b, 16);
1454         if (i == -1) {
1455             throw new IllegalArgumentException("Invalid URL encoding: not a valid digit (radix 16): " + b);
1456         }
1457         return i;
1458     }
1459 
1460     /**
1461      * Encodes an array of bytes into an array of URL safe 7-bit characters. Unsafe characters are escaped.
1462      * @param urlsafe bitset of characters deemed URL safe
1463      * @param bytes  array of bytes to convert to URL safe characters
1464      * @return array of bytes containing URL safe characters
1465      */
1466     public static byte[] encodeUrl(final BitSet urlsafe, final byte[] bytes) {
1467         // adapted from apache commons codec
1468         if (bytes == null) {
1469             return null;
1470         }
1471 
1472         final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
1473         for (final byte c : bytes) {
1474             int b = c;
1475             if (b < 0) {
1476                 b = 256 + b;
1477             }
1478             if (urlsafe.get(b)) {
1479                 if (b == ' ') {
1480                     b = '+';
1481                 }
1482                 buffer.write(b);
1483             }
1484             else {
1485                 buffer.write('%');
1486                 final char hex1 = hexDigit(b >> 4);
1487                 final char hex2 = hexDigit(b);
1488                 buffer.write(hex1);
1489                 buffer.write(hex2);
1490             }
1491         }
1492         return buffer.toByteArray();
1493     }
1494 
1495     private static char hexDigit(final int b) {
1496         return Character.toUpperCase(Character.forDigit(b & 0xF, 16));
1497     }
1498 
1499     /**
1500      * Determines whether two URLs share the same origin according to the Same-Origin Policy.
1501      * Two URLs are considered to have the same origin if they have the same protocol (scheme),
1502      * host, and port.
1503      *
1504      * <p>The method handles default ports correctly by using the URL's default port when
1505      * the explicit port is -1 (indicating no port was specified).
1506      * </p>
1507      *
1508      * @param originUrl the first URL to compare (must not be null)
1509      * @param newUrl the second URL to compare (must not be null)
1510      * @return {@code true} if both URLs have the same host and effective port; {@code false} otherwise
1511      */
1512     public static boolean isSameOrigin(final URL originUrl, final URL newUrl) {
1513         if (!originUrl.getProtocol().equals(newUrl.getProtocol())) {
1514             return false;
1515         }
1516 
1517         if (!originUrl.getHost().equalsIgnoreCase(newUrl.getHost())) {
1518             return false;
1519         }
1520 
1521         int originPort = originUrl.getPort();
1522         if (originPort == -1) {
1523             originPort = originUrl.getDefaultPort();
1524         }
1525         int newPort = newUrl.getPort();
1526         if (newPort == -1) {
1527             newPort = newUrl.getDefaultPort();
1528         }
1529         return originPort == newPort;
1530     }
1531 }