001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.util;
018
019import java.io.UnsupportedEncodingException;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.net.URLEncoder;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.Iterator;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.regex.Pattern;
030
031/**
032 * URI utilities.
033 */
034public final class URISupport {
035
036    public static final String RAW_TOKEN_PREFIX = "RAW";
037    public static final char[] RAW_TOKEN_START = {'(', '{'};
038    public static final char[] RAW_TOKEN_END = {')', '}'};
039
040    // Match any key-value pair in the URI query string whose key contains
041    // "passphrase" or "password" or secret key (case-insensitive).
042    // First capture group is the key, second is the value.
043    private static final Pattern SECRETS = Pattern.compile("([?&][^=]*(?:passphrase|password|secretKey|accessToken|clientSecret|authorizationToken|saslJaasConfig)[^=]*)=(RAW[({].*[)}]|[^&]*)", Pattern.CASE_INSENSITIVE);
044
045    // Match the user password in the URI as second capture group
046    // (applies to URI with authority component and userinfo token in the form
047    // "user:password").
048    private static final Pattern USERINFO_PASSWORD = Pattern.compile("(.*://.*?:)(.*)(@)");
049
050    // Match the user password in the URI path as second capture group
051    // (applies to URI path with authority component and userinfo token in the
052    // form "user:password").
053    private static final Pattern PATH_USERINFO_PASSWORD = Pattern.compile("(.*?:)(.*)(@)");
054
055    private static final String CHARSET = "UTF-8";
056
057    private URISupport() {
058        // Helper class
059    }
060
061    /**
062     * Removes detected sensitive information (such as passwords) from the URI
063     * and returns the result.
064     *
065     * @param uri The uri to sanitize.
066     * @see #SECRETS and #USERINFO_PASSWORD for the matched pattern
067     * @return Returns null if the uri is null, otherwise the URI with the
068     *         passphrase, password or secretKey sanitized.
069     */
070    public static String sanitizeUri(String uri) {
071        // use xxxxx as replacement as that works well with JMX also
072        String sanitized = uri;
073        if (uri != null) {
074            sanitized = SECRETS.matcher(sanitized).replaceAll("$1=xxxxxx");
075            sanitized = USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
076        }
077        return sanitized;
078    }
079
080    /**
081     * Removes detected sensitive information (such as passwords) from the
082     * <em>path part</em> of an URI (that is, the part without the query
083     * parameters or component prefix) and returns the result.
084     *
085     * @param path the URI path to sanitize
086     * @return null if the path is null, otherwise the sanitized path
087     */
088    public static String sanitizePath(String path) {
089        String sanitized = path;
090        if (path != null) {
091            sanitized = PATH_USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
092        }
093        return sanitized;
094    }
095
096    /**
097     * Extracts the scheme specific path from the URI that is used as the
098     * remainder option when creating endpoints.
099     *
100     * @param u the URI
101     * @param useRaw whether to force using raw values
102     * @return the remainder path
103     */
104    public static String extractRemainderPath(URI u, boolean useRaw) {
105        String path = useRaw ? u.getRawSchemeSpecificPart() : u.getSchemeSpecificPart();
106
107        // lets trim off any query arguments
108        if (path.startsWith("//")) {
109            path = path.substring(2);
110        }
111        int idx = path.indexOf('?');
112        if (idx > -1) {
113            path = path.substring(0, idx);
114        }
115
116        return path;
117    }
118
119    /**
120     * Parses the query part of the uri (eg the parameters).
121     * <p/>
122     * The URI parameters will by default be URI encoded. However you can define
123     * a parameter values with the syntax: <tt>key=RAW(value)</tt> which tells
124     * Camel to not encode the value, and use the value as is (eg key=value) and
125     * the value has <b>not</b> been encoded.
126     *
127     * @param uri the uri
128     * @return the parameters, or an empty map if no parameters (eg never null)
129     * @throws URISyntaxException is thrown if uri has invalid syntax.
130     * @see #RAW_TOKEN_PREFIX
131     * @see #RAW_TOKEN_START
132     * @see #RAW_TOKEN_END
133     */
134    public static Map<String, Object> parseQuery(String uri) throws URISyntaxException {
135        return parseQuery(uri, false);
136    }
137
138    /**
139     * Parses the query part of the uri (eg the parameters).
140     * <p/>
141     * The URI parameters will by default be URI encoded. However you can define
142     * a parameter values with the syntax: <tt>key=RAW(value)</tt> which tells
143     * Camel to not encode the value, and use the value as is (eg key=value) and
144     * the value has <b>not</b> been encoded.
145     *
146     * @param uri the uri
147     * @param useRaw whether to force using raw values
148     * @return the parameters, or an empty map if no parameters (eg never null)
149     * @throws URISyntaxException is thrown if uri has invalid syntax.
150     * @see #RAW_TOKEN_PREFIX
151     * @see #RAW_TOKEN_START
152     * @see #RAW_TOKEN_END
153     */
154    public static Map<String, Object> parseQuery(String uri, boolean useRaw) throws URISyntaxException {
155        return parseQuery(uri, useRaw, false);
156    }
157
158    /**
159     * Parses the query part of the uri (eg the parameters).
160     * <p/>
161     * The URI parameters will by default be URI encoded. However you can define
162     * a parameter values with the syntax: <tt>key=RAW(value)</tt> which tells
163     * Camel to not encode the value, and use the value as is (eg key=value) and
164     * the value has <b>not</b> been encoded.
165     *
166     * @param uri the uri
167     * @param useRaw whether to force using raw values
168     * @param lenient whether to parse lenient and ignore trailing & markers
169     *            which has no key or value which can happen when using HTTP
170     *            components
171     * @return the parameters, or an empty map if no parameters (eg never null)
172     * @throws URISyntaxException is thrown if uri has invalid syntax.
173     * @see #RAW_TOKEN_PREFIX
174     * @see #RAW_TOKEN_START
175     * @see #RAW_TOKEN_END
176     */
177    public static Map<String, Object> parseQuery(String uri, boolean useRaw, boolean lenient) throws URISyntaxException {
178        if (uri == null || uri.isEmpty()) {
179            // return an empty map
180            return new LinkedHashMap<>(0);
181        }
182
183        // must check for trailing & as the uri.split("&") will ignore those
184        if (!lenient && uri.endsWith("&")) {
185            throw new URISyntaxException(uri, "Invalid uri syntax: Trailing & marker found. " + "Check the uri and remove the trailing & marker.");
186        }
187
188        URIScanner scanner = new URIScanner();
189        return scanner.parseQuery(uri, useRaw);
190    }
191
192    /**
193     * Scans RAW tokens in the string and returns the list of pair indexes which
194     * tell where a RAW token starts and ends in the string.
195     * <p/>
196     * This is a companion method with {@link #isRaw(int, List)} and the
197     * returned value is supposed to be used as the parameter of that method.
198     *
199     * @param str the string to scan RAW tokens
200     * @return the list of pair indexes which represent the start and end
201     *         positions of a RAW token
202     * @see #isRaw(int, List)
203     * @see #RAW_TOKEN_PREFIX
204     * @see #RAW_TOKEN_START
205     * @see #RAW_TOKEN_END
206     */
207    public static List<Pair<Integer>> scanRaw(String str) {
208        return URIScanner.scanRaw(str);
209    }
210
211    /**
212     * Tests if the index is within any pair of the start and end indexes which
213     * represent the start and end positions of a RAW token.
214     * <p/>
215     * This is a companion method with {@link #scanRaw(String)} and is supposed
216     * to consume the returned value of that method as the second parameter
217     * <tt>pairs</tt>.
218     *
219     * @param index the index to be tested
220     * @param pairs the list of pair indexes which represent the start and end
221     *            positions of a RAW token
222     * @return <tt>true</tt> if the index is within any pair of the indexes,
223     *         <tt>false</tt> otherwise
224     * @see #scanRaw(String)
225     * @see #RAW_TOKEN_PREFIX
226     * @see #RAW_TOKEN_START
227     * @see #RAW_TOKEN_END
228     */
229    public static boolean isRaw(int index, List<Pair<Integer>> pairs) {
230        if (pairs == null || pairs.isEmpty()) {
231            return false;
232        }
233
234        for (Pair<Integer> pair : pairs) {
235            if (index < pair.getLeft()) {
236                return false;
237            }
238            if (index <= pair.getRight()) {
239                return true;
240            }
241        }
242        return false;
243    }
244
245    /**
246     * Parses the query parameters of the uri (eg the query part).
247     *
248     * @param uri the uri
249     * @return the parameters, or an empty map if no parameters (eg never null)
250     * @throws URISyntaxException is thrown if uri has invalid syntax.
251     */
252    public static Map<String, Object> parseParameters(URI uri) throws URISyntaxException {
253        String query = prepareQuery(uri);
254        if (query == null) {
255            // empty an empty map
256            return new LinkedHashMap<>(0);
257        }
258        return parseQuery(query);
259    }
260
261    public static String prepareQuery(URI uri) {
262        String query = uri.getQuery();
263        if (query == null) {
264            String schemeSpecificPart = uri.getSchemeSpecificPart();
265            int idx = schemeSpecificPart.indexOf('?');
266            if (idx < 0) {
267                return null;
268            } else {
269                query = schemeSpecificPart.substring(idx + 1);
270            }
271        } else if (query.indexOf('?') == 0) {
272            // skip leading query
273            query = query.substring(1);
274        }
275        return query;
276    }
277
278
279
280    /**
281     * Traverses the given parameters, and resolve any parameter values which
282     * uses the RAW token syntax: <tt>key=RAW(value)</tt>. This method will then
283     * remove the RAW tokens, and replace the content of the value, with just
284     * the value.
285     *
286     * @param parameters the uri parameters
287     * @see #parseQuery(String)
288     * @see #RAW_TOKEN_PREFIX
289     * @see #RAW_TOKEN_START
290     * @see #RAW_TOKEN_END
291     */
292    @SuppressWarnings("unchecked")
293    public static void resolveRawParameterValues(Map<String, Object> parameters) {
294        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
295            if (entry.getValue() == null) {
296                continue;
297            }
298            // if the value is a list then we need to iterate
299            Object value = entry.getValue();
300            if (value instanceof List) {
301                List list = (List)value;
302                for (int i = 0; i < list.size(); i++) {
303                    Object obj = list.get(i);
304                    if (obj == null) {
305                        continue;
306                    }
307                    String str = obj.toString();
308                    String raw = URIScanner.resolveRaw(str);
309                    if (raw != null) {
310                        // update the string in the list
311                        list.set(i, raw);
312                    }
313                }
314            } else {
315                String str = entry.getValue().toString();
316                String raw = URIScanner.resolveRaw(str);
317                if (raw != null) {
318                    entry.setValue(raw);
319                }
320            }
321        }
322    }
323
324    /**
325     * Creates a URI with the given query
326     *
327     * @param uri the uri
328     * @param query the query to append to the uri
329     * @return uri with the query appended
330     * @throws URISyntaxException is thrown if uri has invalid syntax.
331     */
332    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
333        ObjectHelper.notNull(uri, "uri");
334
335        // assemble string as new uri and replace parameters with the query
336        // instead
337        String s = uri.toString();
338        String before = StringHelper.before(s, "?");
339        if (before == null) {
340            before = StringHelper.before(s, "#");
341        }
342        if (before != null) {
343            s = before;
344        }
345        if (query != null) {
346            s = s + "?" + query;
347        }
348        if ((!s.contains("#")) && (uri.getFragment() != null)) {
349            s = s + "#" + uri.getFragment();
350        }
351
352        return new URI(s);
353    }
354
355    /**
356     * Strips the prefix from the value.
357     * <p/>
358     * Returns the value as-is if not starting with the prefix.
359     *
360     * @param value the value
361     * @param prefix the prefix to remove from value
362     * @return the value without the prefix
363     */
364    public static String stripPrefix(String value, String prefix) {
365        if (value == null || prefix == null) {
366            return value;
367        }
368
369        if (value.startsWith(prefix)) {
370            return value.substring(prefix.length());
371        }
372
373        return value;
374    }
375
376    /**
377     * Strips the suffix from the value.
378     * <p/>
379     * Returns the value as-is if not ending with the prefix.
380     *
381     * @param value the value
382     * @param suffix the suffix to remove from value
383     * @return the value without the suffix
384     */
385    public static String stripSuffix(final String value, final String suffix) {
386        if (value == null || suffix == null) {
387            return value;
388        }
389
390        if (value.endsWith(suffix)) {
391            return value.substring(0, value.length() - suffix.length());
392        }
393
394        return value;
395    }
396
397    /**
398     * Assembles a query from the given map.
399     *
400     * @param options the map with the options (eg key/value pairs)
401     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an
402     *         empty string if there is no options.
403     * @throws URISyntaxException is thrown if uri has invalid syntax.
404     */
405    @SuppressWarnings("unchecked")
406    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
407        return createQueryString(options.keySet(), options);
408    }
409
410    public static String createQueryString(Collection<String> sortedKeys, Map<String, Object> options) throws URISyntaxException {
411        try {
412            if (options.size() > 0) {
413                StringBuilder rc = new StringBuilder();
414                boolean first = true;
415                for (Object o : sortedKeys) {
416                    if (first) {
417                        first = false;
418                    } else {
419                        rc.append("&");
420                    }
421
422                    String key = (String)o;
423                    Object value = options.get(key);
424
425                    // the value may be a list since the same key has multiple
426                    // values
427                    if (value instanceof List) {
428                        List<String> list = (List<String>)value;
429                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
430                            String s = it.next();
431                            appendQueryStringParameter(key, s, rc);
432                            // append & separator if there is more in the list
433                            // to append
434                            if (it.hasNext()) {
435                                rc.append("&");
436                            }
437                        }
438                    } else {
439                        // use the value as a String
440                        String s = value != null ? value.toString() : null;
441                        appendQueryStringParameter(key, s, rc);
442                    }
443                }
444                return rc.toString();
445            } else {
446                return "";
447            }
448        } catch (UnsupportedEncodingException e) {
449            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
450            se.initCause(e);
451            throw se;
452        }
453    }
454
455    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
456        rc.append(URLEncoder.encode(key, CHARSET));
457        if (value == null) {
458            return;
459        }
460        // only append if value is not null
461        rc.append("=");
462        String raw = URIScanner.resolveRaw(value);
463        if (raw != null) {
464            // do not encode RAW parameters unless it has %
465            // need to replace % with %25 to avoid losing "%" when decoding
466            String s = StringHelper.replaceAll(value, "%", "%25");
467            rc.append(s);
468        } else {
469            rc.append(URLEncoder.encode(value, CHARSET));
470        }
471    }
472
473    /**
474     * Creates a URI from the original URI and the remaining parameters
475     * <p/>
476     * Used by various Camel components
477     */
478    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
479        String s = createQueryString(params);
480        if (s.length() == 0) {
481            s = null;
482        }
483        return createURIWithQuery(originalURI, s);
484    }
485
486    /**
487     * Appends the given parameters to the given URI.
488     * <p/>
489     * It keeps the original parameters and if a new parameter is already
490     * defined in {@code originalURI}, it will be replaced by its value in
491     * {@code newParameters}.
492     *
493     * @param originalURI the original URI
494     * @param newParameters the parameters to add
495     * @return the URI with all the parameters
496     * @throws URISyntaxException is thrown if the uri syntax is invalid
497     * @throws UnsupportedEncodingException is thrown if encoding error
498     */
499    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters) throws URISyntaxException, UnsupportedEncodingException {
500        URI uri = new URI(normalizeUri(originalURI));
501        Map<String, Object> parameters = parseParameters(uri);
502        parameters.putAll(newParameters);
503        return createRemainingURI(uri, parameters).toString();
504    }
505
506    /**
507     * Normalizes the uri by reordering the parameters so they are sorted and
508     * thus we can use the uris for endpoint matching.
509     * <p/>
510     * The URI parameters will by default be URI encoded. However you can define
511     * a parameter values with the syntax: <tt>key=RAW(value)</tt> which tells
512     * Camel to not encode the value, and use the value as is (eg key=value) and
513     * the value has <b>not</b> been encoded.
514     *
515     * @param uri the uri
516     * @return the normalized uri
517     * @throws URISyntaxException in thrown if the uri syntax is invalid
518     * @throws UnsupportedEncodingException is thrown if encoding error
519     * @see #RAW_TOKEN_PREFIX
520     * @see #RAW_TOKEN_START
521     * @see #RAW_TOKEN_END
522     */
523    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
524        // try to parse using the simpler and faster Camel URI parser
525        String[] parts = CamelURIParser.parseUri(uri);
526        if (parts != null) {
527            // use the faster and more simple normalizer
528            return doFastNormalizeUri(parts);
529        } else {
530            // use the legacy normalizer as the uri is complex and may have unsafe URL characters
531            return doComplexNormalizeUri(uri);
532        }
533    }
534
535    /**
536     * The complex (and Camel 2.x) compatible URI normalizer when the URI is more complex
537     * such as having percent encoded values, or other unsafe URL characters, or have authority user/password, etc.
538     */
539    private static String doComplexNormalizeUri(String uri) throws URISyntaxException {
540        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
541        String scheme = u.getScheme();
542        String path = u.getSchemeSpecificPart();
543
544        // not possible to normalize
545        if (scheme == null || path == null) {
546            return uri;
547        }
548
549        // find start and end position in path as we only check the context-path and not the query parameters
550        int start = path.startsWith("//") ? 2 : 0;
551        int end = path.indexOf('?');
552        if (start == 0 && end == 0 || start == 2 && end == 2) {
553            // special when there is no context path
554            path = "";
555        } else {
556            if (start != 0 && end == -1) {
557                path = path.substring(start);
558            } else if (end != -1) {
559                path = path.substring(start, end);
560            }
561            if (scheme.startsWith("http")) {
562                path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
563            } else {
564                path = UnsafeUriCharactersEncoder.encode(path);
565            }
566        }
567
568        // okay if we have user info in the path and they use @ in username or password,
569        // then we need to encode them (but leave the last @ sign before the hostname)
570        // this is needed as Camel end users may not encode their user info properly,
571        // but expect this to work out of the box with Camel, and hence we need to
572        // fix it for them
573        int idxPath = path.indexOf('/');
574        if (StringHelper.countChar(path, '@', idxPath) > 1) {
575            String userInfoPath = idxPath > 0 ? path.substring(0, idxPath) : path;
576            int max = userInfoPath.lastIndexOf('@');
577            String before = userInfoPath.substring(0, max);
578            // after must be from original path
579            String after = path.substring(max);
580
581            // replace the @ with %40
582            before = StringHelper.replaceAll(before, "@", "%40");
583            path = before + after;
584        }
585
586        // in case there are parameters we should reorder them
587        String query = prepareQuery(u);
588        if (query == null) {
589            // no parameters then just return
590            return buildUri(scheme, path, null);
591        } else {
592            Map<String, Object> parameters = URISupport.parseQuery(query, false, false);
593            if (parameters.size() == 1) {
594                // only 1 parameter need to create new query string
595                query = URISupport.createQueryString(parameters);
596                return buildUri(scheme, path, query);
597            } else {
598                // reorder parameters a..z
599                List<String> keys = new ArrayList<>(parameters.keySet());
600                keys.sort(null);
601
602                // build uri object with sorted parameters
603                query = URISupport.createQueryString(keys, parameters);
604                return buildUri(scheme, path, query);
605            }
606        }
607    }
608
609    /**
610     * The fast parser for normalizing Camel endpoint URIs when the URI is not complex and
611     * can be parsed in a much more efficient way.
612     */
613    private static String doFastNormalizeUri(String[] parts) throws URISyntaxException {
614        String scheme = parts[0];
615        String path = parts[1];
616        String query = parts[2];
617
618        // in case there are parameters we should reorder them
619        if (query == null) {
620            // no parameters then just return
621            return buildUri(scheme, path, null);
622        } else {
623            Map<String, Object> parameters = null;
624            if (query.indexOf('&') != -1) {
625                // only parse if there is parameters
626                parameters = URISupport.parseQuery(query, false, false);
627            }
628            if (parameters == null || parameters.size() == 1) {
629                return buildUri(scheme, path, query);
630            } else {
631                // reorder parameters a..z
632                // optimize and only build new query if the keys was resorted
633                boolean sort = false;
634                String prev = null;
635                for (String key : parameters.keySet()) {
636                    if (prev == null) {
637                        prev = key;
638                    } else {
639                        int comp = key.compareTo(prev);
640                        if (comp < 0) {
641                            sort = true;
642                            break;
643                        }
644                    }
645                }
646                if (sort) {
647                    List<String> keys = new ArrayList<>(parameters.keySet());
648                    keys.sort(null);
649                    // rebuild query with sorted parameters
650                    query = URISupport.createQueryString(keys, parameters);
651                }
652
653                return buildUri(scheme, path, query);
654            }
655        }
656    }
657
658    private static String buildUri(String scheme, String path, String query) {
659        // must include :// to do a correct URI all components can work with
660        int len = scheme.length() + 3 + path.length();
661        if (query != null) {
662            len += 1 + query.length();
663            StringBuilder sb = new StringBuilder(len);
664            sb.append(scheme).append("://").append(path).append('?').append(query);
665            return sb.toString();
666        } else {
667            StringBuilder sb = new StringBuilder(len);
668            sb.append(scheme).append("://").append(path);
669            return sb.toString();
670        }
671    }
672
673    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
674        Map<String, Object> rc = new LinkedHashMap<>(properties.size());
675
676        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
677            Map.Entry<String, Object> entry = it.next();
678            String name = entry.getKey();
679            if (name.startsWith(optionPrefix)) {
680                Object value = properties.get(name);
681                name = name.substring(optionPrefix.length());
682                rc.put(name, value);
683                it.remove();
684            }
685        }
686
687        return rc;
688    }
689
690    public static String pathAndQueryOf(final URI uri) {
691        final String path = uri.getPath();
692
693        String pathAndQuery = path;
694        if (ObjectHelper.isEmpty(path)) {
695            pathAndQuery = "/";
696        }
697
698        final String query = uri.getQuery();
699        if (ObjectHelper.isNotEmpty(query)) {
700            pathAndQuery += "?" + query;
701        }
702
703        return pathAndQuery;
704    }
705
706    public static String joinPaths(final String... paths) {
707        if (paths == null || paths.length == 0) {
708            return "";
709        }
710
711        final StringBuilder joined = new StringBuilder();
712
713        boolean addedLast = false;
714        for (int i = paths.length - 1; i >= 0; i--) {
715            String path = paths[i];
716            if (ObjectHelper.isNotEmpty(path)) {
717                if (addedLast) {
718                    path = stripSuffix(path, "/");
719                }
720
721                addedLast = true;
722
723                if (path.charAt(0) == '/') {
724                    joined.insert(0, path);
725                } else {
726                    if (i > 0) {
727                        joined.insert(0, '/').insert(1, path);
728                    } else {
729                        joined.insert(0, path);
730                    }
731                }
732            }
733        }
734
735        return joined.toString();
736    }
737}