IOUtils.java

/*
 * Copyright 2017-2020 original authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.micronaut.core.io;

import io.micronaut.core.annotation.Blocking;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.IOExceptionBiFunction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.Reader;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Stream;

/**
 * Utility methods for I/O operations.
 *
 * @author Graeme Rocher
 * @since 1.0
 */
@SuppressWarnings("java:S1118")
public class IOUtils {
    // Do NOT introduce a static logger into this class, as it is used
    // by our features at image build time: this will prevent the native
    // images from building. If you need a logger, introduce it for debugging
    // but remove it before committing your changes.

    private static final int BUFFER_MAX = 8192;
    private static final String SCHEME_FILE = "file";
    private static final String SCHEME_JAR = "jar";
    private static final String SCHEME_ZIP = "zip";
    private static final String SCHEME_WSJAR = "wsjar";

    private static final String COLON = ":";

    /**
     * Iterates over each directory in a JAR or file system.
     *
     * @param url      The URL
     * @param path     The path
     * @param consumer The consumer
     * @since 3.5.0
     */
    @Blocking
    @SuppressWarnings({"java:S2095", "S1141"})
    public static void eachFile(@NonNull URL url, String path, @NonNull Consumer<Path> consumer) {
        try {
            eachFile(url.toURI(), path, consumer);
        } catch (URISyntaxException e) {
            // ignore and proceed
        }
    }

    /**
     * Iterates over each directory in a JAR or file system.
     *
     * @param uri      The URI
     * @param path     The path
     * @param consumer The consumer
     * @since 3.5.0
     */
    @Blocking
    @SuppressWarnings({"java:S2095", "java:S1141", "java:S3776"})
    public static void eachFile(@NonNull URI uri, String path, @NonNull Consumer<Path> consumer) {
        List<Closeable> toClose = new ArrayList<>();
        try {
            Path myPath = resolvePath(uri, path, toClose, IOUtils::loadNestedJarUri);
            if (myPath != null) {
                // use this method instead of Files#walk to eliminate the Stream overhead
                Files.walkFileTree(myPath, Collections.emptySet(), 1, new FileVisitor<>() {
                    @Override
                    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                        return FileVisitResult.CONTINUE;
                    }

                    @Override
                    public FileVisitResult visitFile(Path currentPath, BasicFileAttributes attrs) throws IOException {
                        if (currentPath.equals(myPath) || Files.isHidden(currentPath) || currentPath.getFileName().startsWith(".")) {
                            return FileVisitResult.CONTINUE;
                        }
                        consumer.accept(currentPath);
                        return FileVisitResult.CONTINUE;
                    }

                    @Override
                    public FileVisitResult visitFileFailed(Path file, IOException exc) {
                        return FileVisitResult.CONTINUE;
                    }

                    @Override
                    public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
                        return FileVisitResult.CONTINUE;
                    }
                });
            }
        } catch (IOException e) {
            // ignore, can't do anything here and can't log because class used in compiler
        } finally {
            for (Closeable closeable : toClose) {
                try {
                    closeable.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    /**
     * Resolve the path in the URI.
     *
     * @param uri     The URI
     * @param path    The path
     * @param toClose to close hooks
     * @return The path resolved
     * @throws IOException
     * @since 4.7
     */
    @Nullable
    public static Path resolvePath(@NonNull URI uri,
                                   @NonNull String path,
                                   @NonNull List<Closeable> toClose) throws IOException {
        return resolvePath(uri, path, toClose, IOUtils::loadNestedJarUri);
    }

    @Nullable
    static Path resolvePath(@NonNull URI uri,
                            String path,
                            List<Closeable> toClose,
                            IOExceptionBiFunction<List<Closeable>, String, Path> loadNestedJarUriFunction) throws IOException {
        String scheme = uri.getScheme();
        try {
            if (SCHEME_JAR.equals(scheme) || SCHEME_ZIP.equals(scheme) || SCHEME_WSJAR.equals(scheme)) {
                // try to match FileSystems.newFileSystem(URI) semantics for zipfs here.
                // Basically ignores anything after the !/ if it exists, and uses the part
                // before as the jar path to extract.
                String jarUri = uri.getRawSchemeSpecificPart();
                int sep = jarUri.lastIndexOf("!/");
                if (sep != -1) {
                    jarUri = jarUri.substring(0, sep);
                }
                if (!jarUri.startsWith(SCHEME_FILE + COLON)) {
                    // Special case WebLogic classloader
                    // https://github.com/micronaut-projects/micronaut-core/issues/8636
                    jarUri = jarUri.startsWith("/") ?
                        SCHEME_FILE + COLON + jarUri :
                        SCHEME_FILE + COLON + "/" + jarUri;
                }
                // now, add the !/ at the end again so that loadNestedJarUri can handle it:
                jarUri += "!/";
                return loadNestedJarUriFunction.apply(toClose, jarUri).resolve(path);
            } else if ("file".equals(scheme)) {
                return Paths.get(uri).resolve(path);
            } else if ("jrt".equals(scheme)) {
                FileSystem fs = FileSystems.newFileSystem(URI.create("jrt:/"), Map.of());
                return fs.getPath(uri.getPath());
            } else {
                // graal resource: case
                return Paths.get(uri);
            }
        } catch (FileSystemNotFoundException e) {
            return null;
        }
    }

    private static Path loadNestedJarUri(List<Closeable> toClose, String jarUri) throws IOException {
        int sep = jarUri.lastIndexOf("!/");
        if (sep == -1) {
            return Paths.get(URI.create(jarUri));
        }
        Path jarPath = loadNestedJarUri(toClose, jarUri.substring(0, sep));
        if (Files.isDirectory(jarPath)) {
            // spring boot creates weird jar URLs, like 'jar:file:/xyz.jar!/BOOT-INF/classes!/abc'
            // This check makes our class loading resilient to that
            return jarPath;
        }
        FileSystem zipfs = FileSystems.newFileSystem(jarPath, (ClassLoader) null);
        toClose.add(0, zipfs);
        return zipfs.getPath(jarUri.substring(sep + 1));
    }

    /**
     * Read the content of the BufferedReader and return it as a String in a blocking manner.
     * The BufferedReader is closed afterward.
     *
     * @param reader a BufferedReader whose content we want to read
     * @return a String containing the content of the buffered reader
     * @throws IOException if an IOException occurs.
     * @since 1.0
     */
    @Blocking
    public static String readText(BufferedReader reader) throws IOException {
        StringBuilder answer = new StringBuilder();
        if (reader == null) {
            return answer.toString();
        }
        // reading the content of the file within a char buffer
        // allow to keep the correct line endings
        char[] charBuffer = new char[BUFFER_MAX];
        int nbCharRead /* = 0*/;
        try {
            while ((nbCharRead = reader.read(charBuffer)) != -1) {
                // appends buffer
                answer.append(charBuffer, 0, nbCharRead);
            }
            Reader temp = reader;
            reader = null;
            temp.close();
        } finally {
            try {
                if (reader != null) {
                    reader.close();
                }
            } catch (IOException e) {
                Logger logger = LoggerFactory.getLogger(Logger.class);
                if (logger.isWarnEnabled()) {
                    logger.warn("Failed to close reader: {}", e.getMessage(), e);
                }
            }
        }
        return answer.toString();
    }

    /**
     * Find all the resources starting with the path.
     *
     * @param classLoader The classloader
     * @param path        The path
     * @return the resources as URIs
     * @throws IOException The IO exception
     * @since 4.7
     */
    public static List<URI> getResources(ClassLoader classLoader, final String path) throws IOException {
        final Enumeration<URL> micronautResources = classLoader.getResources(path);
        Set<URI> uniqueURIs = new LinkedHashSet<>();
        while (micronautResources.hasMoreElements()) {
            URL url = micronautResources.nextElement();
            try {
                uniqueURIs.add(url.toURI());
            } catch (URISyntaxException e) {
                throw new RuntimeException(e);
            }
        }

        FileSystem jrtProvider = null;
        if (uniqueURIs.isEmpty()) {
            jrtProvider = getJrtProvider(classLoader);
            if (jrtProvider != null) {
                Path modulesPath = jrtProvider.getPath("modules");
                try (Stream<Path> stream = Files.list(modulesPath)) {
                    stream
                        .filter(p -> !p.getFileName().toString().startsWith("jdk.")) // filter out JDK internal modules
                        .filter(p -> !p.getFileName().toString().startsWith("java.")) // filter out JDK public modules
                        .map(p -> p.resolve(path))
                        .filter(Files::exists)
                        .map(modulesPath::resolve)
                        .map(Path::toUri)
                        .forEach(uniqueURIs::add);
                }

                // uri will be jrt:/modules/<module>/META-INF/micronaut/<service>, so we can walk through its files as if it was a directory
            }
        }
        List<URI> uris = new ArrayList<>(uniqueURIs.size());
        for (URI uri : uniqueURIs) {
            String scheme = uri.getScheme();
            if ("file".equals(scheme)) {
                uri = normalizeFilePath(path, uri);
            }
            // on GraalVM there are spurious extra resources that end with # and then a number
            // we ignore this extra ones
            if (!("resource".equals(scheme) && uri.toString().contains("#"))) {
                uris.add(uri);
            }
        }
        if (jrtProvider != null && jrtProvider.isOpen()) {
            try {
                jrtProvider.close();
            } catch (Throwable ignore) {
                // Ignore
            }
        }
        return uris;
    }

    @Nullable
    private static FileSystem getJrtProvider(ClassLoader classLoader) {
        try {
            URI uri = URI.create("jrt:/");
            FileSystem fs = FileSystems.getFileSystem(uri);
            if (fs.isOpen()) {
                return fs;
            }
            fs = FileSystems.newFileSystem(uri, Collections.emptyMap(), classLoader);
            if (fs.isOpen()) {
                return fs;
            }
        } catch (Throwable e) {
            // not available, probably running in Native Image.
        }
        return null;
    }

    private static URI normalizeFilePath(String path, URI uri) {
        Path p = Paths.get(uri);
        if (p.endsWith(path)) {
            Path subpath = Paths.get(path);
            for (int i = 0; i < subpath.getNameCount(); i++) {
                p = p.getParent();
            }
            uri = p.toUri();
        }
        return uri;
    }
}