FileUtil.java

/*
 * Copyright (c) 2017, Stein Eldar Johnsen
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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
 *
 *   http://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 net.morimekta.file;

import net.morimekta.file.internal.DeleteFilesRecursivelyVisitor;
import net.morimekta.file.internal.ListFilesVisitor;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NotLinkException;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;

import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

/**
 * NIO file utility extensions.
 */
public final class FileUtil {
    public static final String CURRENT_DIR = ".";
    public static final String PARENT_DIR = "..";

    /**
     * Read and parse the path to its absolute canonical path.
     * <p>
     * To circumvent the problem that java cached file metadata, including symlink
     * targets, we need to read canonical paths directly. This includes resolving
     * symlinks and relative path resolution (../..).<br>
     * <p>
     * This will read all the file meta each time and not use any of the java file
     * meta caching, so will probably be a little slower. So should not be used
     * repeatedly or too often.
     *
     * @param path The path to make canonical path of.
     * @return The resolved canonical path.
     * @throws IOException If unable to read the path.
     */
    public static Path readCanonicalPath(Path path) throws IOException {
        if (!path.isAbsolute()) {
            path = path.toAbsolutePath();
        }
        if (path.toString().equals(File.separator)) {
            return path;
        }

        String fileName = path.getFileName().toString();
        if (CURRENT_DIR.equals(fileName)) {
            path = path.getParent();
            fileName = path.getFileName().toString();
        }

        // resolve ".." relative to the top of the path.
        int parents = 0;
        while (PARENT_DIR.equals(fileName)) {
            path = path.getParent();
            if (path == null || path.getFileName() == null) {
                throw new IOException("Parent of root does not exist!");
            }
            fileName = path.getFileName().toString();
            ++parents;
        }
        while (parents-- > 0) {
            path = path.getParent();
            if (path == null || path.getFileName() == null) {
                throw new IOException("Parent of root does not exist!");
            }
            fileName = path.getFileName().toString();
        }

        if (path.getParent() != null) {
            Path parent = readCanonicalPath(path.getParent());
            path = parent.resolve(fileName);

            if (Files.isSymbolicLink(path)) {
                path = Files.readSymbolicLink(path);
                if (!path.isAbsolute()) {
                    path = readCanonicalPath(parent.resolve(path));
                }
            }
        }

        return path;
    }

    /**
     * Similar to {@link Files#createSymbolicLink(Path, Path, FileAttribute[])}, but
     * will replace the link if it already exists, and will try to write / replace
     * it as an atomic operation.
     *
     * @param link   The path of the symbolic link. The parent directory of the
     *               link file must already exist, and must be writable.
     *               See {@link Files#createDirectories(Path, FileAttribute[])}
     * @param newTarget The new target path.
     * @throws IOException If unable to create symbolic link.
     */
    public static void replaceSymbolicLink(Path link, Path newTarget) throws IOException {
        link = link.toAbsolutePath();
        if (Files.exists(link, NOFOLLOW_LINKS)) {
            if (!Files.isSymbolicLink(link)) {
                throw new NotLinkException(String.format("%s is not a symbolic link", link));
            }
            // This operation will follow the link. And we want it to.
            Path parent = readCanonicalPath(link.getParent());
            Path temp   = parent.resolve(String.format("..tmp.%x", ThreadLocalRandom.current().nextLong()));
            try {
                Files.createSymbolicLink(temp, newTarget);
                Files.move(temp, link, ATOMIC_MOVE, REPLACE_EXISTING, NOFOLLOW_LINKS);
            } finally {
                Files.deleteIfExists(temp);
            }
        } else {
            Files.createSymbolicLink(link, newTarget);
        }
    }

    /**
     * List all files in the given directory.
     *
     * @param path Directory to list.
     * @return List of files, symlinks and sub-directories in given directory.
     * @throws IOException When unable to list files.
     */
    public static Collection<Path> list(Path path) throws IOException {
        return list(path, false);
    }

    /**
     * List all files in the given directory.
     *
     * @param pathToList Directory to list.
     * @param recursive  If set to true, will not list sub-dirs, but will recursively
     *                   enter the sub-dir and add the files
     * @return List of files in directory.
     * @throws IOException If listing failed.
     */
    public static Collection<Path> list(Path pathToList, boolean recursive) throws IOException {
        if (!Files.isDirectory(pathToList)) {
            return Collections.emptyList();
        }
        List<Path> all = new ArrayList<>();
        Files.walkFileTree(pathToList, new ListFilesVisitor(pathToList, recursive, all::add));
        return all;
    }

    /**
     * Delete the file or directory recursively.
     *
     * @param path The file or directory path.
     * @throws IOException If delete failed at some point.
     */
    public static void deleteRecursively(Path path) throws IOException {
        if (Files.isDirectory(path)) {
            AtomicReference<IOException> exception = new AtomicReference<>();
            Files.walkFileTree(path, new DeleteFilesRecursivelyVisitor(exception));
            if (exception.get() != null) {
                throw exception.get();
            }
        } else {
            Files.deleteIfExists(path);
        }
    }

    private FileUtil() {}
}