From 9e155808ac3bbaf549c5f95e0b62f757cae2a790 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Mon, 28 Sep 2026 23:06:23 +0100 Subject: [PATCH 1/3] feat(SpecsIo): add OS temp and cache directory helpers --- .../src/pt/up/fe/specs/util/SpecsIo.java | 33 +++++++++++++++++++ .../src/pt/up/fe/specs/util/SpecsSystem.java | 9 +++++ .../test/pt/up/fe/specs/util/SpecsIoTest.java | 28 ++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java index 7bb9db3e..2028c3c5 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java @@ -30,6 +30,7 @@ import java.io.ObjectOutputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -1910,6 +1911,38 @@ public static File getTempFolder(String folderName) { return mkdir(systemTemp, folderName); } + /** Creates a unique OS temporary directory. The caller deletes it when done. */ + public static File createTempDirectory(String prefix) { + try { + return Files.createTempDirectory(prefix).toFile(); + } catch (IOException e) { + throw new UncheckedIOException("Could not create temporary directory with prefix '" + prefix + "'", e); + } + } + + /** Returns the user cache directory, creating it if needed. */ + public static File getOsCacheFolder() { + String cacheDir; + if (SpecsSystem.isWindows()) { + String localAppData = System.getenv("LOCALAPPDATA"); + cacheDir = localAppData != null && !localAppData.isBlank() ? localAppData + : new File(System.getProperty("user.home"), "AppData/Local").getAbsolutePath(); + } else if (SpecsSystem.isMac()) { + cacheDir = new File(System.getProperty("user.home"), "Library/Caches").getAbsolutePath(); + } else { + String xdgCacheHome = System.getenv("XDG_CACHE_HOME"); + String home = System.getenv("HOME"); + String fallbackHome = home != null && !home.isBlank() && new File(home).isAbsolute() + ? home + : System.getProperty("user.home"); + cacheDir = xdgCacheHome != null && !xdgCacheHome.isBlank() && new File(xdgCacheHome).isAbsolute() + ? xdgCacheHome + : new File(fallbackHome, ".cache").getAbsolutePath(); + } + + return SpecsIo.mkdir(new File(cacheDir)); + } + /** * List directory contents for a resource folder. Not recursive. This is * basically a brute-force implementation. diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java index cd4400b3..43485977 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java @@ -61,6 +61,7 @@ public class SpecsSystem { private static final boolean IS_LINUX = System.getProperty("os.name").toLowerCase().startsWith("linux"); private static final boolean IS_WINDOWS = System.getProperty("os.name").startsWith("Windows"); + private static final boolean IS_MAC = System.getProperty("os.name").toLowerCase().contains("mac"); private static final Map CACHED_METHODS = new HashMap<>(); private static final Map> CACHED_FIELDS = new HashMap<>(); @@ -874,6 +875,14 @@ public static boolean isWindows() { return IS_WINDOWS; } + /** + * @return true if the JVM is currently executing in a macOS system, false + * otherwise + */ + public static boolean isMac() { + return IS_MAC; + } + /** * Equivalent to class.isInstance. * diff --git a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java index 90c7f35b..0f092500 100644 --- a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java +++ b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java @@ -1041,6 +1041,34 @@ void testTempFileOperations() { assertThat(randomFolder).isNotNull(); } + @Test + @DisplayName("Test OS-created temp directory") + void testCreateTempDirectory() { + File first = SpecsIo.createTempDirectory("specs-test-"); + File second = SpecsIo.createTempDirectory("specs-test-"); + try { + assertThat(first).isDirectory(); + assertThat(first.getName()).startsWith("specs-test-"); + assertThat(first.getParentFile()) + .isEqualTo(new File(System.getProperty("java.io.tmpdir")).getAbsoluteFile()); + assertThat(second).isNotEqualTo(first); + } finally { + SpecsIo.deleteFolder(first); + SpecsIo.deleteFolder(second); + } + } + + @Test + @DisplayName("Test OS cache folder") + void testGetOsCacheFolder() { + File cacheFolder = SpecsIo.getOsCacheFolder(); + assertThat(cacheFolder).isNotNull(); + assertThat(cacheFolder.isDirectory()).isTrue(); + + // Same call returns the same location + assertThat(SpecsIo.getOsCacheFolder()).isEqualTo(cacheFolder); + } + @Test @DisplayName("Test ZIP operations") void testZipOperations(@TempDir Path tempDir) throws IOException { From dc77ccbe46ae2763c14920911221d28aeb9d3852 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Tue, 29 Sep 2026 00:23:27 +0100 Subject: [PATCH 2/3] feat(SpecsIo): add safe temporary directory cleanup helper --- .../src/pt/up/fe/specs/util/SpecsIo.java | 33 +++++++++++++++ .../test/pt/up/fe/specs/util/SpecsIoTest.java | 41 ++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java index 2028c3c5..4f2ae4d0 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java @@ -40,9 +40,13 @@ import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -1920,6 +1924,35 @@ public static File createTempDirectory(String prefix) { } } + /** Deletes a temporary directory and its contents without following symbolic links. */ + public static void deleteTempDirectory(File directory) { + Path root = directory.toPath(); + if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + + try { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path folder, IOException error) throws IOException { + if (error != null) { + throw error; + } + Files.delete(folder); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new UncheckedIOException("Could not delete temporary directory '" + directory + "'", e); + } + } + /** Returns the user cache directory, creating it if needed. */ public static File getOsCacheFolder() { String cacheDir; diff --git a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java index 0f092500..4276635a 100644 --- a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java +++ b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java @@ -13,6 +13,7 @@ import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -25,6 +26,7 @@ import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -1053,8 +1055,43 @@ void testCreateTempDirectory() { .isEqualTo(new File(System.getProperty("java.io.tmpdir")).getAbsoluteFile()); assertThat(second).isNotEqualTo(first); } finally { - SpecsIo.deleteFolder(first); - SpecsIo.deleteFolder(second); + SpecsIo.deleteTempDirectory(first); + SpecsIo.deleteTempDirectory(second); + } + } + + @Test + @DisplayName("Temp cleanup deletes nested contents") + void testDeleteTempDirectory() throws IOException { + File directory = SpecsIo.createTempDirectory("specs-test-"); + try { + Path nested = Files.createDirectory(directory.toPath().resolve("nested")); + Files.writeString(nested.resolve("file.txt"), "content"); + + SpecsIo.deleteTempDirectory(directory); + assertThat(directory).doesNotExist(); + } finally { + SpecsIo.deleteTempDirectory(directory); + } + } + + @Test + @DisplayName("Temp cleanup does not follow directory symlinks") + void testDeleteTempDirectorySymlink(@TempDir Path fixture) throws IOException { + Path outsideFile = Files.writeString(fixture.resolve("keep.txt"), "keep"); + File directory = SpecsIo.createTempDirectory("specs-test-"); + try { + try { + Files.createSymbolicLink(directory.toPath().resolve("outside"), fixture); + } catch (FileSystemException | UnsupportedOperationException | SecurityException e) { + Assumptions.assumeTrue(false, "Symbolic links unavailable: " + e.getMessage()); + } + + SpecsIo.deleteTempDirectory(directory); + assertThat(directory).doesNotExist(); + assertThat(outsideFile).exists(); + } finally { + SpecsIo.deleteTempDirectory(directory); } } From 64d4aa29c674740ebfb81f4f4ab468b91ccc9166 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Tue, 29 Sep 2026 01:22:29 +0100 Subject: [PATCH 3/3] fix(SpecsIo): avoid traversing Windows junctions during temp cleanup --- .../src/pt/up/fe/specs/util/SpecsIo.java | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java index 4f2ae4d0..a9c1bfef 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java @@ -40,11 +40,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.DirectoryStream; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; @@ -1924,33 +1922,30 @@ public static File createTempDirectory(String prefix) { } } - /** Deletes a temporary directory and its contents without following symbolic links. */ + /** Deletes a temporary directory and its contents without traversing links. */ public static void deleteTempDirectory(File directory) { - Path root = directory.toPath(); - if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) { - return; + try { + deleteTempDirectory(directory.toPath()); + } catch (IOException e) { + throw new UncheckedIOException("Could not delete temporary directory '" + directory + "'", e); } + } - try { - Files.walkFileTree(root, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } + private static void deleteTempDirectory(Path path) throws IOException { + if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) { + return; + } - @Override - public FileVisitResult postVisitDirectory(Path folder, IOException error) throws IOException { - if (error != null) { - throw error; - } - Files.delete(folder); - return FileVisitResult.CONTINUE; + BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + // Windows junctions are directories and "other"; delete the link without opening it. + if (attributes.isDirectory() && !attributes.isOther()) { + try (DirectoryStream children = Files.newDirectoryStream(path)) { + for (Path child : children) { + deleteTempDirectory(child); } - }); - } catch (IOException e) { - throw new UncheckedIOException("Could not delete temporary directory '" + directory + "'", e); + } } + Files.delete(path); } /** Returns the user cache directory, creating it if needed. */