diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java index 7bb9db3e..a9c1bfef 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; @@ -40,8 +41,10 @@ import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.DirectoryStream; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -1910,6 +1913,64 @@ 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); + } + } + + /** Deletes a temporary directory and its contents without traversing links. */ + public static void deleteTempDirectory(File directory) { + try { + deleteTempDirectory(directory.toPath()); + } catch (IOException e) { + throw new UncheckedIOException("Could not delete temporary directory '" + directory + "'", e); + } + } + + private static void deleteTempDirectory(Path path) throws IOException { + if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) { + return; + } + + 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); + } + } + } + Files.delete(path); + } + + /** 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..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; @@ -1041,6 +1043,69 @@ 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.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); + } + } + + @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 {