TemporaryAssetFolder.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 java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.Files.newOutputStream;
import static java.nio.file.StandardOpenOption.APPEND;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;

/**
 * A scoped temporary folder that guarantees complete cleanup on close. Any
 * number of files and sub-directories can be created inside it, and all
 * contents are deleted recursively when the folder is closed. Example usage:
 *
 * <pre>{@code
 * try (TemporaryAssetFolder tmp = new TemporaryAssetFolder(base)) {
 *     // create files, run operations, etc.
 * }
 * // all contents are deleted.
 * }</pre>
 *
 * Each instance creates a new temporary directory under the given base
 * directory. This is preferable to standard temporary files in environments
 * where temp storage is backed by memory (e.g. Kubernetes {@code emptyDir}
 * with {@code medium: Memory}), since accumulated temp files would otherwise
 * consume memory until JVM exit.
 */
public class TemporaryAssetFolder implements Closeable {
    private static final String PARENT_DIR = "..";

    private final Path path;

    /**
     * Create a temporary asset folder in {@code /tmp/}.
     *
     * @throws IOException If unable to create the temporary directory.
     */
    public TemporaryAssetFolder() throws IOException {
        this(Paths.get("/tmp/"));
    }

    /**
     * Create a temporary asset folder in the given base directory with
     * the default prefix {@code "tmp"}.
     *
     * @param baseTempDirectory The parent directory to create the temp folder in.
     * @throws IOException If unable to create the temporary directory.
     */
    public TemporaryAssetFolder(Path baseTempDirectory) throws IOException {
        this(baseTempDirectory, "tmp");
    }

    /**
     * Create a temporary asset folder in the given base directory.
     *
     * @param baseTempDirectory The parent directory to create the temp folder in.
     * @param prefix            The prefix for the temporary directory name.
     * @throws IOException If unable to create the temporary directory.
     */
    public TemporaryAssetFolder(Path baseTempDirectory, String prefix) throws IOException {
        path = Files.createTempDirectory(baseTempDirectory, prefix);
    }

    /**
     * List all files in the temporary folder.
     *
     * @return Collection of files in the temporary folder.
     * @throws IOException When unable to list files.
     */
    public Collection<Path> list() throws IOException {
        return FileUtil.list(path);
    }

    /**
     * List all files in the temporary folder.
     *
     * @param recursive If true, recursively list files in sub-directories.
     * @return Collection of files in the temporary folder.
     * @throws IOException When unable to list files.
     */
    public Collection<Path> list(boolean recursive) throws IOException {
        return FileUtil.list(path, recursive);
    }

    /**
     * @return The path to the temporary folder.
     */
    public Path getPath() {
        return path;
    }

    /**
     * @return The temporary folder as a {@link File}.
     */
    public File getFile() {
        return getPath().toFile();
    }

    /**
     * Resolve a relative name to a path within the temporary folder. The name
     * must not be absolute or contain parent directory references.
     *
     * @param name The relative file name to resolve.
     * @return The resolved path.
     * @throws IllegalArgumentException If the name is absolute or references a parent directory.
     */
    public Path resolvePath(String name) {
        if (name.startsWith(File.separator)) {
            throw new IllegalArgumentException("Absolute path not allowed in file name");
        } else if (name.contains(File.separator + PARENT_DIR + File.separator) ||
                   name.startsWith(PARENT_DIR + File.separator)) {
            throw new IllegalArgumentException("Up path not allowed in file name");
        }
        return getPath().resolve(name);
    }

    /**
     * Resolve a relative name to a {@link File} within the temporary folder.
     *
     * @param name The relative file name to resolve.
     * @return The resolved file.
     * @throws IllegalArgumentException If the name is absolute or references a parent directory.
     */
    public File resolveFile(String name) {
        return resolvePath(name).toFile();
    }

    /**
     * Write byte content to a file in the temporary folder. Creates parent
     * directories as needed. Replaces existing content.
     *
     * @param name    The relative file name.
     * @param content The byte content to write.
     * @return The path to the written file.
     * @throws IOException If unable to write the file.
     */
    public Path put(String name, byte[] content) throws IOException {
        Path file = resolvePath(name);
        Path parent = file.getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }
        return Files.write(file, content, TRUNCATE_EXISTING, CREATE);
    }

    /**
     * Write character content to a file in the temporary folder. Creates parent
     * directories as needed. Replaces existing content.
     *
     * @param name    The relative file name.
     * @param content The character content to write.
     * @return The path to the written file.
     * @throws IOException If unable to write the file.
     */
    public Path put(String name, CharSequence content) throws IOException {
        Path file = resolvePath(name);
        Path parent = file.getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }
        return Files.writeString(file, content, TRUNCATE_EXISTING, CREATE);
    }

    /**
     * Get an output stream for writing to a file in the temporary folder.
     *
     * @param name The relative file name.
     * @return An output stream to the file.
     * @throws IOException If unable to open the stream.
     */
    public OutputStream getOutputStream(String name) throws IOException {
        return getOutputStream(name, false);
    }

    /**
     * Get an output stream for writing to a file in the temporary folder.
     * Creates parent directories as needed.
     *
     * @param name   The relative file name.
     * @param append If true, append to existing content instead of replacing.
     * @return An output stream to the file.
     * @throws IOException If unable to open the stream.
     */
    public OutputStream getOutputStream(String name, boolean append) throws IOException {
        Path file = resolvePath(name);
        Path parent = file.getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }
        return newOutputStream(file, CREATE, append ? APPEND : TRUNCATE_EXISTING);
    }

    /**
     * Get a UTF-8 writer for writing to a file in the temporary folder.
     *
     * @param name The relative file name.
     * @return A writer to the file.
     * @throws IOException If unable to open the writer.
     */
    public Writer getWriter(String name) throws IOException {
        return getWriter(name, false);
    }

    /**
     * Get a UTF-8 writer for writing to a file in the temporary folder.
     *
     * @param name   The relative file name.
     * @param append If true, append to existing content instead of replacing.
     * @return A writer to the file.
     * @throws IOException If unable to open the writer.
     */
    public Writer getWriter(String name, boolean append) throws IOException {
        return new OutputStreamWriter(getOutputStream(name, append), UTF_8);
    }

    /**
     * Read the content of a file as a UTF-8 string.
     *
     * @param name The relative file name.
     * @return The file content as a string.
     * @throws IOException If unable to read the file.
     */
    public String getString(String name) throws IOException {
        return new String(getBytes(name), UTF_8);
    }

    /**
     * Read the content of a file as a byte array.
     *
     * @param name The relative file name.
     * @return The file content as bytes.
     * @throws IOException If unable to read the file.
     */
    public byte[] getBytes(String name) throws IOException {
        try (InputStream in = getInputStream(name);
             BufferedInputStream bis = new BufferedInputStream(in)) {
            return bis.readAllBytes();
        }
    }

    /**
     * Get a UTF-8 reader for reading from a file in the temporary folder.
     *
     * @param name The relative file name.
     * @return A reader for the file.
     * @throws IOException If unable to open the reader.
     */
    public Reader getReader(String name) throws IOException {
        return new InputStreamReader(getInputStream(name), UTF_8);
    }

    /**
     * Get an input stream for reading from a file in the temporary folder.
     *
     * @param name The relative file name.
     * @return An input stream for the file.
     * @throws IOException If unable to open the stream.
     */
    public InputStream getInputStream(String name) throws IOException {
        return Files.newInputStream(resolvePath(name));
    }

    @Override
    public void close() throws IOException {
        FileUtil.deleteRecursively(path);
    }
}