From 9d4fbbe36d85d71ce850bb83bbfb1ce1d3e8dd23 Mon Sep 17 00:00:00 2001 From: Alexey Semenyuk Date: Wed, 25 Feb 2026 17:43:05 +0000 Subject: [PATCH] 8374222: jpackage will exit with error if it fails to clean the temp directory Reviewed-by: almatvee --- .../internal/DefaultBundlingEnvironment.java | 4 +- .../jdk/jpackage/internal/TempDirectory.java | 145 ++++- .../resources/MainResources.properties | 3 + .../jpackage/test/PathDeletionPreventer.java | 173 ++++++ .../jpackage/internal/TempDirectoryTest.java | 570 ++++++++++++++++++ 5 files changed, 877 insertions(+), 18 deletions(-) create mode 100644 test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PathDeletionPreventer.java create mode 100644 test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/TempDirectoryTest.java diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java index 3a58180aa35..fc51f60aa66 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java @@ -195,11 +195,11 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment { public void createBundle(BundlingOperationDescriptor op, Options cmdline) { final var bundler = getBundlerSupplier(op).get().orElseThrow(); Optional 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 { diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/TempDirectory.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/TempDirectory.java index 50d1701bf0d..345a42c5051 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/TempDirectory.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/TempDirectory.java @@ -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 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.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 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(); + var stopped = Slot.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; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties index 6e5de3d9729..233067d6457 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties @@ -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 diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PathDeletionPreventer.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PathDeletionPreventer.java new file mode 100644 index 00000000000..d16b63d9c69 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PathDeletionPreventer.java @@ -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. + *

+ * 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.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 perms; + } + } + + static final PathDeletionPreventer DEFAULT = new Supplier() { + + @Override + public PathDeletionPreventer get() { + switch (OperatingSystem.current()) { + case WINDOWS -> { + return FileChannelLockPathDeletionPreventer.INSTANCE; + } + default -> { + return ReadOnlyDirectoryPathDeletionPreventer.INSTANCE; + } + } + } + + }.get(); +} diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/TempDirectoryTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/TempDirectoryTest.java new file mode 100644 index 00000000000..221e7d9f433 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/TempDirectoryTest.java @@ -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 expected; + if (limit == 0) { + expected = List.of(); + } else { + expected = List.of(path); + } + assertEquals(expected, listing.paths()); + } + + private static Stream 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 RetryExecutor retryExecutor(Class exceptionType) { + return new RetryExecutor(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 RetryExecutor retryExecutor(Class 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 test_DirectoryListing_listFilesAndEmptyDirectories() { + + var testCases = new ArrayList(); + + Supplier 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 extends RetryExecutor { + + RetryExecutorMock(Class exceptionType, Config config) { + super(exceptionType); + setSleepFunction(_ -> {}); + this.config = Objects.requireNonNull(config); + } + + @SuppressWarnings({ "try", "unchecked" }) + @Override + public RetryExecutor setExecutable(ThrowingFunction>, 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 { + 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 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 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 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); + } + } +}