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