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 for working with paths, symbolic links,
 * listing directory contents, and recursive deletion.
 */
public final class FileUtil {
    public static final String CURRENT_DIR = ".";
    public static final String PARENT_DIR = "..";

    /**
     * Resolve the given path to its absolute canonical form by reading symlink
     * targets and resolving relative segments ({@code .} and {@code ..}) directly
     * from the file system.
     * <p>
     * Unlike {@link java.io.File#getCanonicalPath()} or
     * {@link Path#toRealPath(java.nio.file.LinkOption...)}, this method never
     * uses the JVM's cached symlink targets. The JVM aggressively caches
     * resolved paths, which effectively makes symlinks static within a running
     * process. This method bypasses that cache and always reads the current
     * symlink target, making it suitable for long-running processes that need
     * to detect symlink changes.
     * <p>
     * Because every call reads file metadata from disk, it is slower than
     * cached alternatives and should not be called in tight loops.
     *
     * @param path The path to resolve.
     * @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;
    }

    /**
     * Create or atomically replace a symbolic link. If the link already exists
     * it is replaced in a single atomic move, producing only one file event
     * instead of the two that a delete-then-create sequence would generate.
     * If the link does not exist, it is simply created.
     *
     * @param link      The path of the symbolic link. The parent directory must
     *                  already exist and be writable.
     * @param newTarget The new target path for the symbolic link.
     * @throws NotLinkException If the path exists but is not a symbolic link.
     * @throws IOException      If unable to create or replace the 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 true, recursively enters sub-directories and lists
     *                   their contents instead of listing the sub-directories
     *                   themselves. Symbolic links to directories are listed as
     *                   entries but not followed.
     * @return List of files in the 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 a file or directory recursively. If the path is a directory, all
     * of its contents are deleted before the directory itself. Symbolic links
     * to directories are deleted without following them.
     *
     * @param path The file or directory path to delete.
     * @throws IOException If deletion failed.
     */
    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() {}
}