Repository: junit-team/junit5 (language: java)

Issue:
Warn when deleting symlinks that point outside temp dir
This is a follow-up on a discussion we had about #4301.

## Deliverables

- [x] Document that symlinks in temp dirs are deleted but not their targets (if they reside outside of the temp dir)
- [x] Log a warning for every symlink that is deleted but points to a target outside the temp dir



Discussion / hints:
@marcphilipp 

I understand that version `5.12.0-RC2` is scheduled for release this Friday.
Since there are no contributors, it could delay your timeline, I’d like to help resolve this issue as quickly as possible if I can. 🚀

Additionally, could you provide any additional information that might help in addressing this issue?
Thanks for the offer! However, it's already done in my head (meaning that I "only need to write it down") and since, as you've pointed out, we don't want this to delay the release, I'll take a stab myself. I should probably have assigned myself right away.
> Thanks for the offer! However, it's already done in my head (meaning that I "only need to write it down") and since, as you've pointed out, we don't want this to delay the release, I'll take a stab myself. I should probably have assigned myself right away.

Not at all!

I really appreciate how much you care about the community.
I have no doubt that your decision is the best choice for the JUnit 5 project.
> I will focus on the work I'm currently working on.

I've learned a lot thanks to you.
Looking forward to your amazing work! 🚀

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC2.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC2.adoc
index 0870c7513654..f2e111d13f7e 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC2.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC2.adoc
@@ -45,7 +45,9 @@ repository on GitHub.
 [[release-notes-5.12.0-RC2-junit-jupiter-new-features-and-improvements]]
 ==== New Features and Improvements
 
-* ❓
+* The `@TempDir` now warns when deleting symlinks that target locations outside the
+  temporary directory to signal that the target file or directory is _not_ deleted, only
+  the link to it.
 
 
 [[release-notes-5.12.0-RC2-junit-vintage]]
diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java
index 4657ddd9ead7..4b9053ba237d 100644
--- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java
+++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java
@@ -84,8 +84,11 @@
  * &mdash; when the test method or class has finished execution &mdash; JUnit will
  * attempt to clean up the temporary directory by recursively deleting all files
  * and directories in the temporary directory and, finally, the temporary directory
- * itself. In case deletion of a file or directory fails, an {@link IOException}
- * will be thrown that will cause the test or test class to fail.
+ * itself. Symbolic and other types of links, such as junctions on Windows, are
+ * not followed. A warning is logged when deleting a link that targets a
+ * location outside the temporary directory. In case deletion of a file or
+ * directory fails, an {@link IOException} will be thrown that will cause the
+ * test or test class to fail.
  *
  * <p>The {@link #cleanup} attribute allows you to configure the {@link CleanupMode}.
  * If the cleanup mode is set to {@link CleanupMode#NEVER NEVER}, the temporary
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
index dbcb0a1c80c9..5349499d4f09 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
@@ -404,7 +404,8 @@ private SortedMap<Path, IOException> deleteAllFilesAndDirectories(FileOperations
 				@Override
 				public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
 					LOGGER.trace(() -> "preVisitDirectory: " + dir);
-					if (isLink(dir)) {
+					if (isLinkWithTargetOutsideTempDir(dir)) {
+						warnAboutLinkWithTargetOutsideTempDir("link", dir);
 						delete(dir);
 						return SKIP_SUBTREE;
 					}
@@ -414,12 +415,6 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th
 					return CONTINUE;
 				}
 
-				private boolean isLink(Path dir) throws IOException {
-					// While `Files.walkFileTree` does not follow symbolic links, it may follow other links
-					// such as "junctions" on Windows
-					return !dir.toRealPath().startsWith(rootRealPath);
-				}
-
 				@Override
 				public FileVisitResult visitFileFailed(Path file, IOException exc) {
 					LOGGER.trace(exc, () -> "visitFileFailed: " + file);
@@ -432,8 +427,11 @@ public FileVisitResult visitFileFailed(Path file, IOException exc) {
 				}
 
 				@Override
-				public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) {
+				public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
 					LOGGER.trace(() -> "visitFile: " + file);
+					if (Files.isSymbolicLink(file) && isLinkWithTargetOutsideTempDir(file)) {
+						warnAboutLinkWithTargetOutsideTempDir("symbolic link", file);
+					}
 					delete(file);
 					return CONTINUE;
 				}
@@ -445,6 +443,27 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
 					return CONTINUE;
 				}
 
+				private boolean isLinkWithTargetOutsideTempDir(Path path) {
+					// While `Files.walkFileTree` does not follow symbolic links, it may follow other links
+					// such as "junctions" on Windows
+					try {
+						return !path.toRealPath().startsWith(rootRealPath);
+					}
+					catch (IOException e) {
+						LOGGER.trace(e,
+							() -> "Failed to determine real path for " + path + "; assuming it is not a link");
+						return false;
+					}
+				}
+
+				private void warnAboutLinkWithTargetOutsideTempDir(String linkType, Path file) throws IOException {
+					Path realPath = file.toRealPath();
+					LOGGER.warn(() -> String.format(
+						"Deleting %s from location inside of temp dir (%s) "
+								+ "to location outside of temp dir (%s) but not the target file/directory",
+						linkType, file, realPath));
+				}
+
 				private void delete(Path path) {
 					try {
 						fileOperations.delete(path);


Test patch:
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java
index 2511a441a1bb..37cfcdd57f92 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java
@@ -43,6 +43,7 @@
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.nio.file.FileSystem;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Optional;
 import java.util.logging.Level;
@@ -395,6 +396,65 @@ void onSuccessWithNoException(Class<?> elementType, @TrackLogRecords LogRecordLi
 					.noneMatch(m -> m.startsWith("Skipping cleanup of temp dir"));
 		}
 
+		@DisplayName("deletes symbolic links targeting directory inside temp dir")
+		@ParameterizedTest
+		@ElementTypeSource
+		@DisabledOnOs(WINDOWS)
+		void deletesSymbolicLinksTargetingDirInsideTempDir(Class<?> elementType,
+				@TrackLogRecords LogRecordListener listener) throws IOException {
+
+			reset(factory);
+
+			closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext,
+				extensionContext);
+			var rootDir = closeablePath.get();
+			assertThat(rootDir).isDirectory();
+
+			var subDir = createDirectory(rootDir.resolve("subDir"));
+			Files.createFile(subDir.resolve("file"));
+			Files.createSymbolicLink(rootDir.resolve("symbolicLink"), subDir);
+
+			closeablePath.close();
+
+			verify(factory).close();
+			assertThat(rootDir).doesNotExist();
+			assertThat(listener.stream(Level.WARNING)).map(LogRecord::getMessage).isEmpty();
+
+		}
+
+		@DisplayName("deletes symbolic links targeting directory outside temp dir")
+		@ParameterizedTest
+		@ElementTypeSource
+		@DisabledOnOs(WINDOWS)
+		void deletesSymbolicLinksTargetingDirOutsideTempDir(Class<?> elementType,
+				@TrackLogRecords LogRecordListener listener) throws IOException {
+
+			reset(factory);
+
+			closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext,
+				extensionContext);
+			var rootDir = closeablePath.get();
+			assertThat(rootDir).isDirectory();
+
+			var directoryOutsideTempDir = createTempDirectory("junit-");
+			try {
+				var symbolicLink = createSymbolicLink(rootDir.resolve("symbolicLink"), directoryOutsideTempDir);
+
+				closeablePath.close();
+
+				verify(factory).close();
+				assertThat(rootDir).doesNotExist();
+				assertThat(directoryOutsideTempDir).isDirectory();
+				assertThat(listener.stream(Level.WARNING)) //
+						.map(LogRecord::getMessage) //
+						.contains(("Deleting symbolic link from location inside of temp dir (%s) "
+								+ "to location outside of temp dir (%s) but not the target file/directory").formatted(
+									symbolicLink, directoryOutsideTempDir.toRealPath()));
+			}
+			finally {
+				Files.deleteIfExists(directoryOutsideTempDir);
+			}
+		}
 	}
 
 	static class TestCase {
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java
index 4ace7a57d7b2..d6d161db3381 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java
@@ -24,6 +24,8 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
 
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.MethodOrderer;
@@ -32,9 +34,11 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.fixtures.TrackLogRecords;
 import org.junit.jupiter.api.io.CleanupMode;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
+import org.junit.platform.commons.logging.LogRecordListener;
 import org.junit.platform.launcher.LauncherDiscoveryRequest;
 
 /**
@@ -470,7 +474,8 @@ void deletesBrokenJunctions(@TempDir Path dir) throws Exception {
 		}
 
 		@Test
-		void doesNotFollowJunctions(@TempDir Path tempDir) throws IOException {
+		void doesNotFollowJunctions(@TempDir Path tempDir, @TrackLogRecords LogRecordListener listener)
+				throws IOException {
 			var outsideDir = Files.createDirectory(tempDir.resolve("outside"));
 			var testFile = Files.writeString(outsideDir.resolve("test.txt"), "test");
 
@@ -485,6 +490,10 @@ void doesNotFollowJunctions(@TempDir Path tempDir) throws IOException {
 
 			assertThat(outsideDir).exists();
 			assertThat(testFile).exists();
+			assertThat(listener.stream(Level.WARNING)) //
+					.map(LogRecord::getMessage) //
+					.anyMatch(m -> m.matches(
+						"Deleting link from location inside of temp dir \\(.+\\) to location outside of temp dir \\(.+\\) but not the target file/directory"));
 		}
 
 		@SuppressWarnings("JUnitMalformedDeclaration")