8374222: jpackage will exit with error if it fails to clean the temp directory

Reviewed-by: almatvee
This commit is contained in:
Alexey Semenyuk 2026-02-25 17:43:05 +00:00
parent 0ab8a85e87
commit 9d4fbbe36d
5 changed files with 877 additions and 18 deletions

View File

@ -195,11 +195,11 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment {
public void createBundle(BundlingOperationDescriptor op, Options cmdline) {
final var bundler = getBundlerSupplier(op).get().orElseThrow();
Optional<Path> permanentWorkDirectory = Optional.empty();
try (var tempDir = new TempDirectory(cmdline)) {
try (var tempDir = new TempDirectory(cmdline, Globals.instance().objectFactory())) {
if (!tempDir.deleteOnClose()) {
permanentWorkDirectory = Optional.of(tempDir.path());
}
bundler.accept(tempDir.options());
bundler.accept(tempDir.map(cmdline));
} catch (IOException ex) {
throw new UncheckedIOException(ex);
} finally {

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -26,29 +26,46 @@ package jdk.jpackage.internal;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import jdk.jpackage.internal.cli.Options;
import jdk.jpackage.internal.cli.StandardOption;
import jdk.jpackage.internal.util.FileUtils;
import jdk.jpackage.internal.util.Slot;
final class TempDirectory implements Closeable {
TempDirectory(Options options) throws IOException {
final var tempDir = StandardOption.TEMP_ROOT.findIn(options);
if (tempDir.isPresent()) {
this.path = tempDir.orElseThrow();
this.options = options;
} else {
this.path = Files.createTempDirectory("jdk.jpackage");
this.options = options.copyWithDefaultValue(StandardOption.TEMP_ROOT, path);
}
deleteOnClose = tempDir.isEmpty();
TempDirectory(Options options, RetryExecutorFactory retryExecutorFactory) throws IOException {
this(StandardOption.TEMP_ROOT.findIn(options), retryExecutorFactory);
}
Options options() {
return options;
TempDirectory(Optional<Path> tempDir, RetryExecutorFactory retryExecutorFactory) throws IOException {
this(tempDir.isEmpty() ? Files.createTempDirectory("jdk.jpackage") : tempDir.get(),
tempDir.isEmpty(),
retryExecutorFactory);
}
TempDirectory(Path tempDir, boolean deleteOnClose, RetryExecutorFactory retryExecutorFactory) throws IOException {
this.path = Objects.requireNonNull(tempDir);
this.deleteOnClose = deleteOnClose;
this.retryExecutorFactory = Objects.requireNonNull(retryExecutorFactory);
}
Options map(Options options) {
if (deleteOnClose) {
return options.copyWithDefaultValue(StandardOption.TEMP_ROOT, path);
} else {
return options;
}
}
Path path() {
@ -62,11 +79,107 @@ final class TempDirectory implements Closeable {
@Override
public void close() throws IOException {
if (deleteOnClose) {
FileUtils.deleteRecursive(path);
retryExecutorFactory.<Void, IOException>retryExecutor(IOException.class)
.setMaxAttemptsCount(5)
.setAttemptTimeout(2, TimeUnit.SECONDS)
.setExecutable(context -> {
try {
FileUtils.deleteRecursive(path);
} catch (IOException ex) {
if (!context.isLastAttempt()) {
throw ex;
} else {
// Collect the list of leftover files. Collect at most the first 100 files.
var remainingFiles = DirectoryListing.listFilesAndEmptyDirectories(
path, MAX_REPORTED_UNDELETED_FILE_COUNT).paths();
if (remainingFiles.equals(List.of(path))) {
Log.info(I18N.format("warning.tempdir.cleanup-failed", path));
} else {
remainingFiles.forEach(file -> {
Log.info(I18N.format("warning.tempdir.cleanup-file-failed", file));
});
}
Log.verbose(ex);
}
}
return null;
}).execute();
}
}
record DirectoryListing(List<Path> paths, boolean complete) {
DirectoryListing {
Objects.requireNonNull(paths);
}
static DirectoryListing listFilesAndEmptyDirectories(Path path, int limit) {
Objects.requireNonNull(path);
if (limit < 0) {
throw new IllegalArgumentException();
} else if (limit == 0) {
return new DirectoryListing(List.of(), !Files.exists(path));
}
var paths = new ArrayList<Path>();
var stopped = Slot.<Boolean>createEmpty();
stopped.set(false);
try {
Files.walkFileTree(path, new FileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
try (var walk = Files.walk(dir)) {
if (walk.skip(1).findAny().isEmpty()) {
// This is an empty directory, add it to the list.
return addPath(dir, FileVisitResult.SKIP_SUBTREE);
}
} catch (IOException ex) {
Log.verbose(ex);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
return addPath(file, FileVisitResult.CONTINUE);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
return addPath(file, FileVisitResult.CONTINUE);
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
return FileVisitResult.CONTINUE;
}
private FileVisitResult addPath(Path v, FileVisitResult result) {
if (paths.size() < limit) {
paths.add(v);
return result;
} else {
stopped.set(true);
}
return FileVisitResult.TERMINATE;
}
});
} catch (IOException ex) {
Log.verbose(ex);
}
return new DirectoryListing(Collections.unmodifiableList(paths), !stopped.get());
}
}
private final Path path;
private final Options options;
private final boolean deleteOnClose;
private final RetryExecutorFactory retryExecutorFactory;
private final static int MAX_REPORTED_UNDELETED_FILE_COUNT = 100;
}

View File

@ -109,6 +109,9 @@ error.tool-not-found.advice=Please install "{0}"
error.tool-old-version=Can not find "{0}" {1} or newer
error.tool-old-version.advice=Please install "{0}" {1} or newer
warning.tempdir.cleanup-failed=Warning: Failed to clean-up temporary directory {0}
warning.tempdir.cleanup-file-failed=Warning: Failed to delete "{0}" file in the temporary directory
error.output-bundle-cannot-be-overwritten=Output package file "{0}" exists and can not be removed.
error.blocked.option=jlink option [{0}] is not permitted in --jlink-options

View File

@ -0,0 +1,173 @@
/*
* Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package jdk.jpackage.test;
import java.io.Closeable;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileLock;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import jdk.internal.util.OperatingSystem;
import jdk.jpackage.internal.util.SetBuilder;
/**
* Path deletion preventer. Encapsulates platform-specifics of how to make a
* file or a directory non-deletable.
* <p>
* Implementation should be sufficient to make {@code Files#delete(Path)}
* applied to the path protected from deletion throw.
*/
public sealed interface PathDeletionPreventer {
enum Implementation {
/**
* Uses Java file lock to prevent deletion of a file.
* Works on Windows. Doesn't work on Linux and macOS.
*/
FILE_CHANNEL_LOCK,
/**
* Removes write permission from a non-empty directory to prevent its deletion.
* Works on Linux and macOS. Doesn't work on Windows.
*/
READ_ONLY_NON_EMPTY_DIRECTORY,
;
}
Implementation implementation();
Closeable preventPathDeletion(Path path) throws IOException;
enum FileChannelLockPathDeletionPreventer implements PathDeletionPreventer {
INSTANCE;
@Override
public Implementation implementation() {
return Implementation.FILE_CHANNEL_LOCK;
}
@Override
public Closeable preventPathDeletion(Path path) throws IOException {
return new UndeletablePath(path);
}
private static final class UndeletablePath implements Closeable {
UndeletablePath(Path file) throws IOException {
var fos = new FileOutputStream(Objects.requireNonNull(file).toFile());
boolean lockCreated = false;
try {
this.lock = fos.getChannel().lock();
this.fos = fos;
lockCreated = true;
} finally {
if (!lockCreated) {
fos.close();
}
}
}
@Override
public void close() throws IOException {
try {
lock.close();
} finally {
fos.close();
}
}
private final FileOutputStream fos;
private final FileLock lock;
}
}
enum ReadOnlyDirectoryPathDeletionPreventer implements PathDeletionPreventer {
INSTANCE;
@Override
public Implementation implementation() {
return Implementation.READ_ONLY_NON_EMPTY_DIRECTORY;
}
@Override
public Closeable preventPathDeletion(Path path) throws IOException {
return new UndeletablePath(path);
}
private static final class UndeletablePath implements Closeable {
UndeletablePath(Path dir) throws IOException {
this.dir = Objects.requireNonNull(dir);
// Deliberately don't use Files#createDirectories() as don't want to create missing directories.
try {
Files.createDirectory(dir);
Files.createFile(dir.resolve("empty"));
} catch (FileAlreadyExistsException ex) {
}
perms = Files.getPosixFilePermissions(dir);
if (perms.contains(PosixFilePermission.OWNER_WRITE)) {
Files.setPosixFilePermissions(dir, SetBuilder.<PosixFilePermission>build()
.add(perms)
.remove(PosixFilePermission.OWNER_WRITE)
.emptyAllowed(true)
.create());
}
}
@Override
public void close() throws IOException {
if (perms.contains(PosixFilePermission.OWNER_WRITE)) {
Files.setPosixFilePermissions(dir, perms);
}
}
private final Path dir;
private final Set<PosixFilePermission> perms;
}
}
static final PathDeletionPreventer DEFAULT = new Supplier<PathDeletionPreventer>() {
@Override
public PathDeletionPreventer get() {
switch (OperatingSystem.current()) {
case WINDOWS -> {
return FileChannelLockPathDeletionPreventer.INSTANCE;
}
default -> {
return ReadOnlyDirectoryPathDeletionPreventer.INSTANCE;
}
}
}
}.get();
}

View File

@ -0,0 +1,570 @@
/*
* Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package jdk.jpackage.internal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import jdk.internal.util.OperatingSystem;
import jdk.jpackage.internal.cli.Options;
import jdk.jpackage.internal.cli.StandardOption;
import jdk.jpackage.internal.util.FileUtils;
import jdk.jpackage.internal.util.RetryExecutor;
import jdk.jpackage.internal.util.function.ThrowingFunction;
import jdk.jpackage.internal.util.function.ThrowingSupplier;
import jdk.jpackage.test.PathDeletionPreventer;
import jdk.jpackage.test.PathDeletionPreventer.ReadOnlyDirectoryPathDeletionPreventer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
public class TempDirectoryTest {
@Test
void test_directory_use(@TempDir Path tempDirPath) throws IOException {
try (var tempDir = new TempDirectory(Optional.of(tempDirPath), RetryExecutorFactory.DEFAULT)) {
assertEquals(tempDir.path(), tempDirPath);
assertFalse(tempDir.deleteOnClose());
var cmdline = Options.of(Map.of());
assertSame(cmdline, tempDir.map(cmdline));
}
assertTrue(Files.isDirectory(tempDirPath));
}
@Test
void test_directory_new() throws IOException {
var tempDir = new TempDirectory(Optional.empty(), RetryExecutorFactory.DEFAULT);
try (tempDir) {
assertTrue(Files.isDirectory(tempDir.path()));
assertTrue(tempDir.deleteOnClose());
var cmdline = Options.of(Map.of());
var mappedCmdline = tempDir.map(cmdline);
assertEquals(tempDir.path(), StandardOption.TEMP_ROOT.getFrom(mappedCmdline));
}
assertFalse(Files.isDirectory(tempDir.path()));
}
@SuppressWarnings("try")
@ParameterizedTest
@MethodSource
void test_close(CloseType closeType, @TempDir Path root) {
Globals.main(ThrowingSupplier.toSupplier(() -> {
test_close_impl(closeType, root);
return 0;
}));
}
@ParameterizedTest
@MethodSource
void test_DirectoryListing_listFilesAndEmptyDirectories(
ListFilesAndEmptyDirectoriesTestSpec test, @TempDir Path root) throws IOException {
test.run(root);
}
@Test
void test_DirectoryListing_listFilesAndEmptyDirectories_negative(@TempDir Path root) throws IOException {
assertThrowsExactly(IllegalArgumentException.class, () -> {
TempDirectory.DirectoryListing.listFilesAndEmptyDirectories(root, -1);
});
}
@ParameterizedTest
@CsvSource({"100", "101", "1", "0"})
void test_DirectoryListing_listFilesAndEmptyDirectories_nonexistent(int limit, @TempDir Path root) throws IOException {
var path = root.resolve("foo");
var listing = TempDirectory.DirectoryListing.listFilesAndEmptyDirectories(path, limit);
assertTrue(listing.complete());
List<Path> expected;
if (limit == 0) {
expected = List.of();
} else {
expected = List.of(path);
}
assertEquals(expected, listing.paths());
}
private static Stream<CloseType> test_close() {
switch (PathDeletionPreventer.DEFAULT.implementation()) {
case READ_ONLY_NON_EMPTY_DIRECTORY -> {
return Stream.of(CloseType.values());
}
default -> {
return Stream.of(CloseType.values())
.filter(Predicate.not(Set.of(
CloseType.FAIL_NO_LEFTOVER_FILES,
CloseType.FAIL_NO_LEFTOVER_FILES_VERBOSE)::contains));
}
}
}
@SuppressWarnings({ "try" })
private void test_close_impl(CloseType closeType, Path root) throws IOException {
var logSink = new StringWriter();
var logPrintWriter = new PrintWriter(logSink, true);
Globals.instance().loggerOutputStreams(logPrintWriter, logPrintWriter);
if (closeType.isVerbose()) {
Globals.instance().loggerVerbose();
}
final var workDir = root.resolve("workdir");
Files.createDirectories(workDir);
final Path leftoverPath;
final TempDirectory tempDir;
switch (closeType) {
case FAIL_NO_LEFTOVER_FILES_VERBOSE, FAIL_NO_LEFTOVER_FILES -> {
leftoverPath = workDir;
tempDir = new TempDirectory(workDir, true, new RetryExecutorFactory() {
@Override
public <T, E extends Exception> RetryExecutor<T, E> retryExecutor(Class<? extends E> exceptionType) {
return new RetryExecutor<T, E>(exceptionType).setSleepFunction(_ -> {});
}
});
// Lock the parent directory of the work directory and don't create any files in the work directory.
// This should trigger the error message about the failure to delete the empty work directory.
try (var lockWorkDir = ReadOnlyDirectoryPathDeletionPreventer.INSTANCE.preventPathDeletion(workDir.getParent())) {
tempDir.close();
}
}
default -> {
Files.createFile(workDir.resolve("b"));
final var lockedPath = workDir.resolve("a");
switch (PathDeletionPreventer.DEFAULT.implementation()) {
case FILE_CHANNEL_LOCK -> {
Files.createFile(lockedPath);
leftoverPath = lockedPath;
}
case READ_ONLY_NON_EMPTY_DIRECTORY -> {
Files.createDirectories(lockedPath);
leftoverPath = lockedPath.resolve("a");
Files.createFile(leftoverPath);
}
default -> {
throw new AssertionError();
}
}
tempDir = new TempDirectory(workDir, true, new RetryExecutorFactory() {
@Override
public <T, E extends Exception> RetryExecutor<T, E> retryExecutor(Class<? extends E> exceptionType) {
var config = new RetryExecutorMock.Config(lockedPath, closeType.isSuccess());
return new RetryExecutorMock<>(exceptionType, config);
}
});
tempDir.close();
}
}
logPrintWriter.flush();
var logMessages = new BufferedReader(new StringReader(logSink.toString())).lines().toList();
assertTrue(Files.isDirectory(root));
if (closeType.isSuccess()) {
assertFalse(Files.exists(tempDir.path()));
assertEquals(List.of(), logMessages);
} else {
assertTrue(Files.isDirectory(tempDir.path()));
assertTrue(Files.exists(leftoverPath));
assertFalse(Files.exists(tempDir.path().resolve("b")));
String errMessage;
switch (closeType) {
case FAIL_SOME_LEFTOVER_FILES_VERBOSE, FAIL_SOME_LEFTOVER_FILES -> {
errMessage = "warning.tempdir.cleanup-file-failed";
}
case FAIL_NO_LEFTOVER_FILES_VERBOSE, FAIL_NO_LEFTOVER_FILES -> {
errMessage = "warning.tempdir.cleanup-failed";
}
default -> {
throw new AssertionError();
}
}
assertEquals(List.of(I18N.format(errMessage, leftoverPath)), logMessages.subList(0, 1));
if (closeType.isVerbose()) {
// Check the log contains a stacktrace
assertNotEquals(1, logMessages.size());
}
FileUtils.deleteRecursive(tempDir.path());
}
}
private static Collection<ListFilesAndEmptyDirectoriesTestSpec> test_DirectoryListing_listFilesAndEmptyDirectories() {
var testCases = new ArrayList<ListFilesAndEmptyDirectoriesTestSpec>();
Supplier<ListFilesAndEmptyDirectoriesTestSpec.Builder> builder = ListFilesAndEmptyDirectoriesTestSpec::build;
Stream.of(
builder.get().dirs("").complete(),
builder.get().dirs("").limit(0),
builder.get().dirs("foo").complete(),
builder.get().dirs("foo").limit(0),
builder.get().dirs("foo").limit(1).complete(),
builder.get().dirs("foo").limit(2).complete(),
builder.get().dirs("a/b/c").files("foo").files("b/b", "b/c").complete(),
builder.get().dirs("a/b/c").files("foo").files("b/b", "b/c").limit(4).complete(),
builder.get().dirs("a/b/c").files("foo").files("b/b", "b/c").limit(3)
).map(ListFilesAndEmptyDirectoriesTestSpec.Builder::create).forEach(testCases::add);
if (!OperatingSystem.isWindows()) {
Stream.of(
// A directory with the sibling symlink pointing to this directory
builder.get().dirs("foo").symlink("foo-symlink", "foo").complete(),
// A file with the sibling symlink pointing to this file
builder.get().symlink("foo-symlink", "foo").files("foo").complete(),
// A dangling symlink
builder.get().nonexistent("foo/bar/buz").symlink("dangling-symlink", "foo/bar/buz").complete()
).map(ListFilesAndEmptyDirectoriesTestSpec.Builder::create).forEach(testCases::add);
}
return testCases;
}
enum CloseType {
SUCCEED,
FAIL_SOME_LEFTOVER_FILES,
FAIL_SOME_LEFTOVER_FILES_VERBOSE,
FAIL_NO_LEFTOVER_FILES,
FAIL_NO_LEFTOVER_FILES_VERBOSE,
;
boolean isSuccess() {
return this == SUCCEED;
}
boolean isVerbose() {
return name().endsWith("_VERBOSE");
}
}
private static final class RetryExecutorMock<T, E extends Exception> extends RetryExecutor<T, E> {
RetryExecutorMock(Class<? extends E> exceptionType, Config config) {
super(exceptionType);
setSleepFunction(_ -> {});
this.config = Objects.requireNonNull(config);
}
@SuppressWarnings({ "try", "unchecked" })
@Override
public RetryExecutor<T,E> setExecutable(ThrowingFunction<Context<RetryExecutor<T, E>>, T, E> v) {
return super.setExecutable(context -> {
if (context.isLastAttempt() && config.unlockOnLastAttempt()) {
return v.apply(context);
} else {
try (var lock = PathDeletionPreventer.DEFAULT.preventPathDeletion(config.lockedPath())) {
return v.apply(context);
} catch (IOException ex) {
if (exceptionType().isInstance(ex)) {
throw (E)ex;
} else {
throw new AssertionError();
}
}
}
});
};
private final Config config;
record Config(Path lockedPath, boolean unlockOnLastAttempt) {
Config {
Objects.requireNonNull(lockedPath);
}
}
}
sealed interface FileSpec extends Comparable<FileSpec> {
Path path();
Path create(Path root) throws IOException;
public default int compareTo(FileSpec other) {
return path().compareTo(other.path());
}
static File file(Path path) {
return new File(path);
}
static Directory dir(Path path) {
return new Directory(path);
}
static Nonexistent nonexistent(Path path) {
return new Nonexistent(path);
}
static Symlink symlink(Path path, FileSpec target) {
return new Symlink(path, target);
}
};
record File(Path path) implements FileSpec {
File {
path = normalizePath(path);
if (path.getNameCount() == 0) {
throw new IllegalArgumentException();
}
}
@Override
public Path create(Path root) throws IOException {
var resolvedPath = root.resolve(path);
if (!Files.isRegularFile(resolvedPath)) {
Files.createDirectories(resolvedPath.getParent());
Files.createFile(resolvedPath);
}
return resolvedPath;
}
@Override
public String toString() {
return String.format("f:%s", path);
}
}
record Nonexistent(Path path) implements FileSpec {
Nonexistent {
path = normalizePath(path);
if (path.getNameCount() == 0) {
throw new IllegalArgumentException();
}
}
@Override
public Path create(Path root) throws IOException {
return root.resolve(path);
}
@Override
public String toString() {
return String.format("x:%s", path);
}
}
record Symlink(Path path, FileSpec target) implements FileSpec {
Symlink {
path = normalizePath(path);
if (path.getNameCount() == 0) {
throw new IllegalArgumentException();
}
Objects.requireNonNull(target);
}
@Override
public Path create(Path root) throws IOException {
var resolvedPath = root.resolve(path);
var targetPath = target.create(root);
Files.createDirectories(resolvedPath.getParent());
return Files.createSymbolicLink(resolvedPath, targetPath);
}
@Override
public String toString() {
return String.format("s:%s->%s", path, target);
}
}
record Directory(Path path) implements FileSpec {
Directory {
path = normalizePath(path);
}
@Override
public Path create(Path root) throws IOException {
return Files.createDirectories(root.resolve(path));
}
@Override
public String toString() {
return String.format("d:%s", path);
}
}
private static Path normalizePath(Path path) {
path = path.normalize();
if (path.isAbsolute()) {
throw new IllegalArgumentException();
}
return path;
}
private record ListFilesAndEmptyDirectoriesTestSpec(Set<FileSpec> input, int limit, boolean complete) {
ListFilesAndEmptyDirectoriesTestSpec {
Objects.requireNonNull(input);
if (!(input instanceof SortedSet)) {
input = new TreeSet<>(input);
}
}
static Builder build() {
return new Builder();
}
static final class Builder {
ListFilesAndEmptyDirectoriesTestSpec create() {
return new ListFilesAndEmptyDirectoriesTestSpec(Set.copyOf(input), limit, complete);
}
Builder files(String... paths) {
Stream.of(paths).map(Path::of).map(FileSpec::file).forEach(input::add);
return this;
}
Builder dirs(String... paths) {
Stream.of(paths).map(Path::of).map(FileSpec::dir).forEach(input::add);
return this;
}
Builder nonexistent(String... paths) {
Stream.of(paths).map(Path::of).map(FileSpec::nonexistent).forEach(input::add);
return this;
}
Builder symlink(String path, String target) {
Objects.requireNonNull(target);
var targetSpec = input.stream().filter(v -> {
return v.path().equals(Path.of(target));
}).findFirst();
if (targetSpec.isEmpty()) {
var v = FileSpec.file(Path.of(target));
input.add(v);
targetSpec = Optional.ofNullable(v);
}
input.add(FileSpec.symlink(Path.of(path), targetSpec.get()));
return this;
}
Builder limit(int v) {
limit = v;
return this;
}
Builder complete(boolean v) {
complete = v;
return this;
}
Builder complete() {
return complete(true);
}
private final Set<FileSpec> input = new HashSet<>();
private int limit = Integer.MAX_VALUE;
private boolean complete;
}
void run(Path root) throws IOException {
for (var v : input) {
v.create(root);
}
for (var v : input) {
Predicate<Path> validator;
switch (v) {
case File _ -> {
validator = Files::isRegularFile;
}
case Directory _ -> {
validator = Files::isDirectory;
}
case Symlink _ -> {
validator = Files::isSymbolicLink;
}
case Nonexistent _ -> {
validator = Predicate.not(Files::exists);
}
}
assertTrue(validator.test(root.resolve(v.path())));
}
var listing = TempDirectory.DirectoryListing.listFilesAndEmptyDirectories(root, limit);
assertEquals(complete, listing.complete());
if (complete) {
var actual = listing.paths().stream().peek(p -> {
assertTrue(p.startsWith(root));
}).map(root::relativize).sorted().toList();
var expected = input.stream()
.filter(Predicate.not(Nonexistent.class::isInstance))
.map(FileSpec::path)
.sorted()
.toList();
assertEquals(expected, actual);
} else {
assertEquals(limit, listing.paths().size());
}
}
@Override
public String toString() {
return String.format("%s; limit=%d; complete=%s", input, limit, complete);
}
}
}