001/*
002 * Copyright (C) 2012 The Guava Authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
005 * in compliance with the License. You may obtain a copy of the License at
006 *
007 * http://www.apache.org/licenses/LICENSE-2.0
008 *
009 * Unless required by applicable law or agreed to in writing, software distributed under the License
010 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
011 * or implied. See the License for the specific language governing permissions and limitations under
012 * the License.
013 */
014
015package com.google.common.reflect;
016
017import static com.google.common.base.Preconditions.checkArgument;
018import static com.google.common.base.Preconditions.checkNotNull;
019import static com.google.common.base.StandardSystemProperty.JAVA_CLASS_PATH;
020import static com.google.common.base.StandardSystemProperty.PATH_SEPARATOR;
021import static java.util.logging.Level.WARNING;
022
023import com.google.common.annotations.VisibleForTesting;
024import com.google.common.base.CharMatcher;
025import com.google.common.base.Splitter;
026import com.google.common.collect.FluentIterable;
027import com.google.common.collect.ImmutableList;
028import com.google.common.collect.ImmutableMap;
029import com.google.common.collect.ImmutableSet;
030import com.google.common.io.ByteSource;
031import com.google.common.io.CharSource;
032import com.google.common.io.Resources;
033import java.io.File;
034import java.io.IOException;
035import java.net.MalformedURLException;
036import java.net.URISyntaxException;
037import java.net.URL;
038import java.net.URLClassLoader;
039import java.nio.charset.Charset;
040import java.util.Enumeration;
041import java.util.HashSet;
042import java.util.LinkedHashMap;
043import java.util.Map;
044import java.util.NoSuchElementException;
045import java.util.Set;
046import java.util.jar.Attributes;
047import java.util.jar.JarEntry;
048import java.util.jar.JarFile;
049import java.util.jar.Manifest;
050import java.util.logging.Logger;
051import org.jspecify.annotations.Nullable;
052
053/**
054 * Scans the source of a {@link ClassLoader} and finds all loadable classes and resources.
055 *
056 * <h2>Prefer <a href="https://github.com/classgraph/classgraph/wiki">ClassGraph</a> over {@code
057 * ClassPath}</h2>
058 *
059 * <p>We recommend using <a href="https://github.com/classgraph/classgraph/wiki">ClassGraph</a>
060 * instead of {@code ClassPath}. ClassGraph improves upon {@code ClassPath} in several ways,
061 * including addressing many of its limitations. Limitations of {@code ClassPath} include:
062 *
063 * <ul>
064 *   <li>It looks only for files and JARs in URLs available from {@link URLClassLoader} instances or
065 *       the {@linkplain ClassLoader#getSystemClassLoader() system class loader}. This means it does
066 *       not look for classes in the <i>module path</i>.
067 *   <li>It understands only {@code file:} URLs. This means that it does not understand <a
068 *       href="https://openjdk.java.net/jeps/220">{@code jrt:/} URLs</a>, among <a
069 *       href="https://github.com/classgraph/classgraph/wiki/Classpath-specification-mechanisms">others</a>.
070 *   <li>It does not know how to look for classes when running under an Android VM. (ClassGraph does
071 *       not support this directly, either, but ClassGraph documents how to <a
072 *       href="https://github.com/classgraph/classgraph/wiki/Build-Time-Scanning">perform build-time
073 *       classpath scanning and make the results available to an Android app</a>.)
074 *   <li>Like all of Guava, it is not tested under Windows. We have gotten <a
075 *       href="https://github.com/google/guava/issues/2130">a report of a specific bug under
076 *       Windows</a>.
077 *   <li>It <a href="https://github.com/google/guava/issues/2712">returns only one resource for a
078 *       given path</a>, even if resources with that path appear in multiple jars or directories.
079 *   <li>It assumes that <a href="https://github.com/google/guava/issues/3349">any class with a
080 *       {@code $} in its name is a nested class</a>.
081 * </ul>
082 *
083 * <h2>{@code ClassPath} and symlinks</h2>
084 *
085 * <p>In the case of directory classloaders, symlinks are supported but cycles are not traversed.
086 * This guarantees discovery of each <em>unique</em> loadable resource. However, not all possible
087 * aliases for resources on cyclic paths will be listed.
088 *
089 * @author Ben Yu
090 * @since 14.0
091 */
092public final class ClassPath {
093  private static final Logger logger = Logger.getLogger(ClassPath.class.getName());
094
095  /** Separator for the Class-Path manifest attribute value in jar files. */
096  private static final Splitter CLASS_PATH_ATTRIBUTE_SEPARATOR =
097      Splitter.on(" ").omitEmptyStrings();
098
099  private static final String CLASS_FILE_NAME_EXTENSION = ".class";
100
101  private final ImmutableSet<ResourceInfo> resources;
102
103  private ClassPath(ImmutableSet<ResourceInfo> resources) {
104    this.resources = resources;
105  }
106
107  /**
108   * Returns a {@code ClassPath} representing all classes and resources loadable from {@code
109   * classloader} and its ancestor class loaders.
110   *
111   * <p><b>Warning:</b> {@code ClassPath} can find classes and resources only from:
112   *
113   * <ul>
114   *   <li>{@link URLClassLoader} instances' {@code file:} URLs
115   *   <li>the {@linkplain ClassLoader#getSystemClassLoader() system class loader}. To search the
116   *       system class loader even when it is not a {@link URLClassLoader} (as in Java 9), {@code
117   *       ClassPath} searches the files from the {@code java.class.path} system property.
118   * </ul>
119   *
120   * @throws IOException if the attempt to read class path resources (jar files or directories)
121   *     failed.
122   */
123  public static ClassPath from(ClassLoader classloader) throws IOException {
124    ImmutableSet<LocationInfo> locations = locationsFrom(classloader);
125
126    // Add all locations to the scanned set so that in a classpath [jar1, jar2], where jar1 has a
127    // manifest with Class-Path pointing to jar2, we won't scan jar2 twice.
128    Set<File> scanned = new HashSet<>();
129    for (LocationInfo location : locations) {
130      scanned.add(location.file());
131    }
132
133    // Scan all locations
134    ImmutableSet.Builder<ResourceInfo> builder = ImmutableSet.builder();
135    for (LocationInfo location : locations) {
136      builder.addAll(location.scanResources(scanned));
137    }
138    return new ClassPath(builder.build());
139  }
140
141  /**
142   * Returns all resources loadable from the current class path, including the class files of all
143   * loadable classes but excluding the "META-INF/MANIFEST.MF" file.
144   */
145  public ImmutableSet<ResourceInfo> getResources() {
146    return resources;
147  }
148
149  /**
150   * Returns all classes loadable from the current class path.
151   *
152   * @since 16.0
153   */
154  public ImmutableSet<ClassInfo> getAllClasses() {
155    return FluentIterable.from(resources).filter(ClassInfo.class).toSet();
156  }
157
158  /**
159   * Returns all top level classes loadable from the current class path. Note that "top-level-ness"
160   * is determined heuristically by class name (see {@link ClassInfo#isTopLevel}).
161   */
162  public ImmutableSet<ClassInfo> getTopLevelClasses() {
163    return FluentIterable.from(resources)
164        .filter(ClassInfo.class)
165        .filter(ClassInfo::isTopLevel)
166        .toSet();
167  }
168
169  /** Returns all top level classes whose package name is {@code packageName}. */
170  public ImmutableSet<ClassInfo> getTopLevelClasses(String packageName) {
171    checkNotNull(packageName);
172    ImmutableSet.Builder<ClassInfo> builder = ImmutableSet.builder();
173    for (ClassInfo classInfo : getTopLevelClasses()) {
174      if (classInfo.getPackageName().equals(packageName)) {
175        builder.add(classInfo);
176      }
177    }
178    return builder.build();
179  }
180
181  /**
182   * Returns all top level classes whose package name is {@code packageName} or starts with {@code
183   * packageName} followed by a '.'.
184   */
185  public ImmutableSet<ClassInfo> getTopLevelClassesRecursive(String packageName) {
186    checkNotNull(packageName);
187    String packagePrefix = packageName + '.';
188    ImmutableSet.Builder<ClassInfo> builder = ImmutableSet.builder();
189    for (ClassInfo classInfo : getTopLevelClasses()) {
190      if (classInfo.getName().startsWith(packagePrefix)) {
191        builder.add(classInfo);
192      }
193    }
194    return builder.build();
195  }
196
197  /**
198   * Represents a class path resource that can be either a class file or any other resource file
199   * loadable from the class path.
200   *
201   * @since 14.0
202   */
203  public static class ResourceInfo {
204    private final File file;
205    private final String resourceName;
206
207    final ClassLoader loader;
208
209    static ResourceInfo of(File file, String resourceName, ClassLoader loader) {
210      if (resourceName.endsWith(CLASS_FILE_NAME_EXTENSION)) {
211        return new ClassInfo(file, resourceName, loader);
212      } else {
213        return new ResourceInfo(file, resourceName, loader);
214      }
215    }
216
217    ResourceInfo(File file, String resourceName, ClassLoader loader) {
218      this.file = checkNotNull(file);
219      this.resourceName = checkNotNull(resourceName);
220      this.loader = checkNotNull(loader);
221    }
222
223    /**
224     * Returns the url identifying the resource.
225     *
226     * <p>See {@link ClassLoader#getResource}
227     *
228     * @throws NoSuchElementException if the resource cannot be loaded through the class loader,
229     *     despite physically existing in the class path.
230     */
231    public final URL url() {
232      URL url = loader.getResource(resourceName);
233      if (url == null) {
234        throw new NoSuchElementException(resourceName);
235      }
236      return url;
237    }
238
239    /**
240     * Returns a {@link ByteSource} view of the resource from which its bytes can be read.
241     *
242     * @throws NoSuchElementException if the resource cannot be loaded through the class loader,
243     *     despite physically existing in the class path.
244     * @since 20.0
245     */
246    public final ByteSource asByteSource() {
247      return Resources.asByteSource(url());
248    }
249
250    /**
251     * Returns a {@link CharSource} view of the resource from which its bytes can be read as
252     * characters decoded with the given {@code charset}.
253     *
254     * @throws NoSuchElementException if the resource cannot be loaded through the class loader,
255     *     despite physically existing in the class path.
256     * @since 20.0
257     */
258    public final CharSource asCharSource(Charset charset) {
259      return Resources.asCharSource(url(), charset);
260    }
261
262    /** Returns the fully qualified name of the resource. Such as "com/mycomp/foo/bar.txt". */
263    public final String getResourceName() {
264      return resourceName;
265    }
266
267    /** Returns the file that includes this resource. */
268    final File getFile() {
269      return file;
270    }
271
272    @Override
273    public int hashCode() {
274      return resourceName.hashCode();
275    }
276
277    @Override
278    public boolean equals(@Nullable Object obj) {
279      if (obj instanceof ResourceInfo) {
280        ResourceInfo that = (ResourceInfo) obj;
281        return resourceName.equals(that.resourceName) && loader == that.loader;
282      }
283      return false;
284    }
285
286    // Do not change this arbitrarily. We rely on it for sorting ResourceInfo.
287    @Override
288    public String toString() {
289      return resourceName;
290    }
291  }
292
293  /**
294   * Represents a class that can be loaded through {@link #load}.
295   *
296   * @since 14.0
297   */
298  public static final class ClassInfo extends ResourceInfo {
299    private final String className;
300
301    ClassInfo(File file, String resourceName, ClassLoader loader) {
302      super(file, resourceName, loader);
303      this.className = getClassName(resourceName);
304    }
305
306    /**
307     * Returns the package name of the class, without attempting to load the class.
308     *
309     * <p>Behaves similarly to {@code class.getPackage().}{@link Package#getName() getName()} but
310     * does not require the class (or package) to be loaded.
311     *
312     * <p>But note that this method may behave differently for a class in the default package: For
313     * such classes, this method always returns an empty string. But under some version of Java,
314     * {@code class.getPackage().getName()} produces a {@code NullPointerException} because {@code
315     * class.getPackage()} returns {@code null}.
316     */
317    public String getPackageName() {
318      return Reflection.getPackageName(className);
319    }
320
321    /**
322     * Returns the simple name of the underlying class as given in the source code.
323     *
324     * <p>Behaves similarly to {@link Class#getSimpleName()} but does not require the class to be
325     * loaded.
326     *
327     * <p>But note that this class uses heuristics to identify the simple name. See a related
328     * discussion in <a href="https://github.com/google/guava/issues/3349">issue 3349</a>.
329     */
330    public String getSimpleName() {
331      int lastDollarSign = className.lastIndexOf('$');
332      if (lastDollarSign != -1) {
333        String innerClassName = className.substring(lastDollarSign + 1);
334        // local and anonymous classes are prefixed with number (1,2,3...), anonymous classes are
335        // entirely numeric whereas local classes have the user supplied name as a suffix
336        return CharMatcher.inRange('0', '9').trimLeadingFrom(innerClassName);
337      }
338      String packageName = getPackageName();
339      if (packageName.isEmpty()) {
340        return className;
341      }
342
343      // Since this is a top level class, its simple name is always the part after package name.
344      return className.substring(packageName.length() + 1);
345    }
346
347    /**
348     * Returns the fully qualified name of the class.
349     *
350     * <p>Behaves identically to {@link Class#getName()} but does not require the class to be
351     * loaded.
352     */
353    public String getName() {
354      return className;
355    }
356
357    /**
358     * Returns true if the class name "looks to be" top level (not nested), that is, it includes no
359     * '$' in the name. This method may return false for a top-level class that's intentionally
360     * named with the '$' character. If this is a concern, you could use {@link #load} and then
361     * check on the loaded {@link Class} object instead.
362     *
363     * @since 30.1
364     */
365    public boolean isTopLevel() {
366      return className.indexOf('$') == -1;
367    }
368
369    /**
370     * Loads (but doesn't link or initialize) the class.
371     *
372     * @throws LinkageError when there were errors in loading classes that this class depends on.
373     *     For example, {@link NoClassDefFoundError}.
374     */
375    public Class<?> load() {
376      try {
377        return loader.loadClass(className);
378      } catch (ClassNotFoundException e) {
379        // Shouldn't happen, since the class name is read from the class path.
380        throw new IllegalStateException(e);
381      }
382    }
383
384    @Override
385    public String toString() {
386      return className;
387    }
388  }
389
390  /**
391   * Returns all locations that {@code classloader} and parent loaders load classes and resources
392   * from. Callers can {@linkplain LocationInfo#scanResources scan} individual locations selectively
393   * or even in parallel.
394   */
395  static ImmutableSet<LocationInfo> locationsFrom(ClassLoader classloader) {
396    ImmutableSet.Builder<LocationInfo> builder = ImmutableSet.builder();
397    for (Map.Entry<File, ClassLoader> entry : getClassPathEntries(classloader).entrySet()) {
398      builder.add(new LocationInfo(entry.getKey(), entry.getValue()));
399    }
400    return builder.build();
401  }
402
403  /**
404   * Represents a single location (a directory or a jar file) in the class path and is responsible
405   * for scanning resources from this location.
406   */
407  static final class LocationInfo {
408    final File home;
409    private final ClassLoader classloader;
410
411    LocationInfo(File home, ClassLoader classloader) {
412      this.home = checkNotNull(home);
413      this.classloader = checkNotNull(classloader);
414    }
415
416    /** Returns the file this location is from. */
417    public File file() {
418      return home;
419    }
420
421    /** Scans this location and returns all scanned resources. */
422    public ImmutableSet<ResourceInfo> scanResources() throws IOException {
423      return scanResources(new HashSet<File>());
424    }
425
426    /**
427     * Scans this location and returns all scanned resources.
428     *
429     * <p>This file and jar files from "Class-Path" entry in the scanned manifest files will be
430     * added to {@code scannedFiles}.
431     *
432     * <p>A file will be scanned at most once even if specified multiple times by one or multiple
433     * jar files' "Class-Path" manifest entries. Particularly, if a jar file from the "Class-Path"
434     * manifest entry is already in {@code scannedFiles}, either because it was scanned earlier, or
435     * it was intentionally added to the set by the caller, it will not be scanned again.
436     *
437     * <p>Note that when you call {@code location.scanResources(scannedFiles)}, the location will
438     * always be scanned even if {@code scannedFiles} already contains it.
439     */
440    public ImmutableSet<ResourceInfo> scanResources(Set<File> scannedFiles) throws IOException {
441      ImmutableSet.Builder<ResourceInfo> builder = ImmutableSet.builder();
442      scannedFiles.add(home);
443      scan(home, scannedFiles, builder);
444      return builder.build();
445    }
446
447    private void scan(File file, Set<File> scannedUris, ImmutableSet.Builder<ResourceInfo> builder)
448        throws IOException {
449      try {
450        if (!file.exists()) {
451          return;
452        }
453      } catch (SecurityException e) {
454        logger.warning("Cannot access " + file + ": " + e);
455        // TODO(emcmanus): consider whether to log other failure cases too.
456        return;
457      }
458      if (file.isDirectory()) {
459        scanDirectory(file, builder);
460      } else {
461        scanJar(file, scannedUris, builder);
462      }
463    }
464
465    private void scanJar(
466        File file, Set<File> scannedUris, ImmutableSet.Builder<ResourceInfo> builder)
467        throws IOException {
468      JarFile jarFile;
469      try {
470        jarFile = new JarFile(file);
471      } catch (IOException e) {
472        // Not a jar file
473        return;
474      }
475      try {
476        for (File path : getClassPathFromManifest(file, jarFile.getManifest())) {
477          // We only scan each file once independent of the classloader that file might be
478          // associated with.
479          if (scannedUris.add(path.getCanonicalFile())) {
480            scan(path, scannedUris, builder);
481          }
482        }
483        scanJarFile(jarFile, builder);
484      } finally {
485        try {
486          jarFile.close();
487        } catch (IOException ignored) { // similar to try-with-resources, but don't fail scanning
488        }
489      }
490    }
491
492    private void scanJarFile(JarFile file, ImmutableSet.Builder<ResourceInfo> builder) {
493      Enumeration<JarEntry> entries = file.entries();
494      while (entries.hasMoreElements()) {
495        JarEntry entry = entries.nextElement();
496        if (entry.isDirectory() || entry.getName().equals(JarFile.MANIFEST_NAME)) {
497          continue;
498        }
499        builder.add(ResourceInfo.of(new File(file.getName()), entry.getName(), classloader));
500      }
501    }
502
503    private void scanDirectory(File directory, ImmutableSet.Builder<ResourceInfo> builder)
504        throws IOException {
505      Set<File> currentPath = new HashSet<>();
506      currentPath.add(directory.getCanonicalFile());
507      scanDirectory(directory, "", currentPath, builder);
508    }
509
510    /**
511     * Recursively scan the given directory, adding resources for each file encountered. Symlinks
512     * which have already been traversed in the current tree path will be skipped to eliminate
513     * cycles; otherwise symlinks are traversed.
514     *
515     * @param directory the root of the directory to scan
516     * @param packagePrefix resource path prefix inside {@code classloader} for any files found
517     *     under {@code directory}
518     * @param currentPath canonical files already visited in the current directory tree path, for
519     *     cycle elimination
520     */
521    private void scanDirectory(
522        File directory,
523        String packagePrefix,
524        Set<File> currentPath,
525        ImmutableSet.Builder<ResourceInfo> builder)
526        throws IOException {
527      File[] files = directory.listFiles();
528      if (files == null) {
529        logger.warning("Cannot read directory " + directory);
530        // IO error, just skip the directory
531        return;
532      }
533      for (File f : files) {
534        String name = f.getName();
535        if (f.isDirectory()) {
536          File deref = f.getCanonicalFile();
537          if (currentPath.add(deref)) {
538            scanDirectory(deref, packagePrefix + name + "/", currentPath, builder);
539            currentPath.remove(deref);
540          }
541        } else {
542          String resourceName = packagePrefix + name;
543          if (!resourceName.equals(JarFile.MANIFEST_NAME)) {
544            builder.add(ResourceInfo.of(f, resourceName, classloader));
545          }
546        }
547      }
548    }
549
550    @Override
551    public boolean equals(@Nullable Object obj) {
552      if (obj instanceof LocationInfo) {
553        LocationInfo that = (LocationInfo) obj;
554        return home.equals(that.home) && classloader.equals(that.classloader);
555      }
556      return false;
557    }
558
559    @Override
560    public int hashCode() {
561      return home.hashCode();
562    }
563
564    @Override
565    public String toString() {
566      return home.toString();
567    }
568  }
569
570  /**
571   * Returns the class path URIs specified by the {@code Class-Path} manifest attribute, according
572   * to <a
573   * href="https://docs.oracle.com/en/java/javase/26/docs/specs/jar/jar.html#main-attributes">JAR
574   * File Specification</a>. If {@code manifest} is null, it means the jar file has no manifest, and
575   * an empty set will be returned.
576   */
577  @VisibleForTesting
578  static ImmutableSet<File> getClassPathFromManifest(File jarFile, @Nullable Manifest manifest) {
579    if (manifest == null) {
580      return ImmutableSet.of();
581    }
582    ImmutableSet.Builder<File> builder = ImmutableSet.builder();
583    String classpathAttribute =
584        manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH.toString());
585    if (classpathAttribute != null) {
586      for (String path : CLASS_PATH_ATTRIBUTE_SEPARATOR.split(classpathAttribute)) {
587        URL url;
588        try {
589          url = getClassPathEntry(jarFile, path);
590        } catch (MalformedURLException e) {
591          // Ignore bad entry
592          logger.warning("Invalid Class-Path entry: " + path);
593          continue;
594        }
595        if (url.getProtocol().equals("file")) {
596          builder.add(toFile(url));
597        }
598      }
599    }
600    return builder.build();
601  }
602
603  @VisibleForTesting
604  static ImmutableMap<File, ClassLoader> getClassPathEntries(ClassLoader classloader) {
605    LinkedHashMap<File, ClassLoader> entries = new LinkedHashMap<>();
606    // Search parent first, since it's the order ClassLoader#loadClass() uses.
607    ClassLoader parent = classloader.getParent();
608    if (parent != null) {
609      entries.putAll(getClassPathEntries(parent));
610    }
611    for (URL url : getClassLoaderUrls(classloader)) {
612      if (url.getProtocol().equals("file")) {
613        File file = toFile(url);
614        if (!entries.containsKey(file)) {
615          entries.put(file, classloader);
616        }
617      }
618    }
619    return ImmutableMap.copyOf(entries);
620  }
621
622  private static ImmutableList<URL> getClassLoaderUrls(ClassLoader classloader) {
623    if (classloader instanceof URLClassLoader) {
624      return ImmutableList.copyOf(((URLClassLoader) classloader).getURLs());
625    }
626    if (classloader.equals(ClassLoader.getSystemClassLoader())) {
627      return parseJavaClassPath();
628    }
629    return ImmutableList.of();
630  }
631
632  /**
633   * Returns the URLs in the class path specified by the {@code java.class.path} {@linkplain
634   * System#getProperty system property}.
635   */
636  @VisibleForTesting // TODO(b/65488446): Make this a public API.
637  static ImmutableList<URL> parseJavaClassPath() {
638    ImmutableList.Builder<URL> urls = ImmutableList.builder();
639    for (String entry : Splitter.on(PATH_SEPARATOR.value()).split(JAVA_CLASS_PATH.value())) {
640      try {
641        try {
642          urls.add(new File(entry).toURI().toURL());
643        } catch (SecurityException e) { // File.toURI checks to see if the file is a directory
644          urls.add(new URL("file", null, new File(entry).getAbsolutePath()));
645        }
646      } catch (MalformedURLException e) {
647        logger.log(WARNING, "malformed classpath entry: " + entry, e);
648      }
649    }
650    return urls.build();
651  }
652
653  /**
654   * Returns the absolute uri of the Class-Path entry value as specified in <a
655   * href="https://docs.oracle.com/en/java/javase/26/docs/specs/jar/jar.html#main-attributes">JAR
656   * File Specification</a>. Even though the specification only talks about relative urls, absolute
657   * urls are actually supported too (for example, in Maven surefire plugin).
658   */
659  @VisibleForTesting
660  static URL getClassPathEntry(File jarFile, String path) throws MalformedURLException {
661    return new URL(jarFile.toURI().toURL(), path);
662  }
663
664  @VisibleForTesting
665  static String getClassName(String filename) {
666    int classNameEnd = filename.length() - CLASS_FILE_NAME_EXTENSION.length();
667    return filename.substring(0, classNameEnd).replace('/', '.');
668  }
669
670  // TODO(benyu): Try java.nio.file.Paths#get() when Guava drops JDK 6 support.
671  @VisibleForTesting
672  static File toFile(URL url) {
673    checkArgument(url.getProtocol().equals("file"));
674    try {
675      return new File(url.toURI()); // Accepts escaped characters like %20.
676    } catch (URISyntaxException e) { // URL.toURI() doesn't escape chars.
677      return new File(url.getPath()); // Accepts non-escaped chars like space.
678    }
679  }
680}