Skip to content

Commit 655f559

Browse files
authored
Fix handling of Windows junctions during temp dir cleanup (#4301)
* Stop following working junctions (same as symlinks) * Fix deleting broken junctions Fixes #4299.
1 parent 40c885d commit 655f559

File tree

5 files changed

+108
-15
lines changed

5 files changed

+108
-15
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC1.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ repository on GitHub.
3636
[[release-notes-5.12.0-RC1-junit-jupiter-bug-fixes]]
3737
==== Bug Fixes
3838

39-
* ❓
39+
* Fix handling of "junctions" on Windows during `@TempDir` cleanup: junctions will no
40+
longer be followed when deleting directories and broken junctions will be deleted.
4041

4142
[[release-notes-5.12.0-RC1-junit-jupiter-deprecations-and-breaking-changes]]
4243
==== Deprecations and Breaking Changes

junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package org.junit.jupiter.engine.extension;
1212

1313
import static java.nio.file.FileVisitResult.CONTINUE;
14+
import static java.nio.file.FileVisitResult.SKIP_SUBTREE;
1415
import static java.util.stream.Collectors.joining;
1516
import static org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope.TEST_METHOD;
1617
import static org.junit.jupiter.api.io.CleanupMode.DEFAULT;
@@ -32,6 +33,7 @@
3233
import java.nio.file.FileSystems;
3334
import java.nio.file.FileVisitResult;
3435
import java.nio.file.Files;
36+
import java.nio.file.LinkOption;
3537
import java.nio.file.NoSuchFileException;
3638
import java.nio.file.Path;
3739
import java.nio.file.Paths;
@@ -289,7 +291,7 @@ private static boolean selfOrChildFailed(ExtensionContext context) {
289291

290292
static class CloseablePath implements CloseableResource {
291293

292-
private static final Logger logger = LoggerFactory.getLogger(CloseablePath.class);
294+
private static final Logger LOGGER = LoggerFactory.getLogger(CloseablePath.class);
293295

294296
private final Path dir;
295297
private final TempDirFactory factory;
@@ -327,15 +329,27 @@ public void close() throws IOException {
327329
try {
328330
if (this.cleanupMode == NEVER
329331
|| (this.cleanupMode == ON_SUCCESS && selfOrChildFailed(this.extensionContext))) {
330-
logger.info(() -> String.format("Skipping cleanup of temp dir %s for %s due to CleanupMode.%s.",
332+
LOGGER.info(() -> String.format("Skipping cleanup of temp dir %s for %s due to CleanupMode.%s.",
331333
this.dir, descriptionFor(this.annotatedElement), this.cleanupMode.name()));
332334
return;
333335
}
334336

335337
FileOperations fileOperations = this.extensionContext.getStore(NAMESPACE) //
336338
.getOrDefault(FILE_OPERATIONS_KEY, FileOperations.class, FileOperations.DEFAULT);
339+
FileOperations loggingFileOperations = file -> {
340+
LOGGER.trace(() -> "Attempting to delete " + file);
341+
try {
342+
fileOperations.delete(file);
343+
LOGGER.trace(() -> "Successfully deleted " + file);
344+
}
345+
catch (IOException e) {
346+
LOGGER.trace(e, () -> "Failed to delete " + file);
347+
throw e;
348+
}
349+
};
337350

338-
SortedMap<Path, IOException> failures = deleteAllFilesAndDirectories(fileOperations);
351+
LOGGER.trace(() -> "Cleaning up temp dir " + this.dir);
352+
SortedMap<Path, IOException> failures = deleteAllFilesAndDirectories(loggingFileOperations);
339353
if (!failures.isEmpty()) {
340354
throw createIOExceptionWithAttachedFailures(failures);
341355
}
@@ -375,26 +389,41 @@ private static String descriptionFor(Executable executable) {
375389
private SortedMap<Path, IOException> deleteAllFilesAndDirectories(FileOperations fileOperations)
376390
throws IOException {
377391

378-
if (this.dir == null || Files.notExists(this.dir)) {
392+
Path rootDir = this.dir;
393+
if (rootDir == null || Files.notExists(rootDir)) {
379394
return Collections.emptySortedMap();
380395
}
381396

382397
SortedMap<Path, IOException> failures = new TreeMap<>();
383398
Set<Path> retriedPaths = new HashSet<>();
384-
tryToResetPermissions(this.dir);
385-
Files.walkFileTree(this.dir, new SimpleFileVisitor<Path>() {
399+
Path rootRealPath = rootDir.toRealPath();
400+
401+
tryToResetPermissions(rootDir);
402+
Files.walkFileTree(rootDir, new SimpleFileVisitor<Path>() {
386403

387404
@Override
388-
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
389-
if (!dir.equals(CloseablePath.this.dir)) {
405+
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
406+
LOGGER.trace(() -> "preVisitDirectory: " + dir);
407+
if (isLink(dir)) {
408+
delete(dir);
409+
return SKIP_SUBTREE;
410+
}
411+
if (!dir.equals(rootDir)) {
390412
tryToResetPermissions(dir);
391413
}
392414
return CONTINUE;
393415
}
394416

417+
private boolean isLink(Path dir) throws IOException {
418+
// While `Files.walkFileTree` does not follow symbolic links, it may follow other links
419+
// such as "junctions" on Windows
420+
return !dir.toRealPath().startsWith(rootRealPath);
421+
}
422+
395423
@Override
396424
public FileVisitResult visitFileFailed(Path file, IOException exc) {
397-
if (exc instanceof NoSuchFileException) {
425+
LOGGER.trace(exc, () -> "visitFileFailed: " + file);
426+
if (exc instanceof NoSuchFileException && !Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
398427
return CONTINUE;
399428
}
400429
// IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags
@@ -404,15 +433,19 @@ public FileVisitResult visitFileFailed(Path file, IOException exc) {
404433

405434
@Override
406435
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) {
407-
return deleteAndContinue(file);
436+
LOGGER.trace(() -> "visitFile: " + file);
437+
delete(file);
438+
return CONTINUE;
408439
}
409440

410441
@Override
411442
public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
412-
return deleteAndContinue(dir);
443+
LOGGER.trace(exc, () -> "postVisitDirectory: " + dir);
444+
delete(dir);
445+
return CONTINUE;
413446
}
414447

415-
private FileVisitResult deleteAndContinue(Path path) {
448+
private void delete(Path path) {
416449
try {
417450
fileOperations.delete(path);
418451
}
@@ -426,7 +459,6 @@ private FileVisitResult deleteAndContinue(Path path) {
426459
// IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags
427460
resetPermissionsAndTryToDeleteAgain(path, exception);
428461
}
429-
return CONTINUE;
430462
}
431463

432464
private void resetPermissionsAndTryToDeleteAgain(Path path, IOException exception) {

jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import static java.nio.file.Files.deleteIfExists;
1414
import static org.assertj.core.api.Assertions.assertThat;
1515
import static org.junit.jupiter.api.Assertions.fail;
16+
import static org.junit.jupiter.api.condition.OS.WINDOWS;
1617
import static org.junit.jupiter.api.io.CleanupMode.ALWAYS;
1718
import static org.junit.jupiter.api.io.CleanupMode.NEVER;
1819
import static org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS;
@@ -21,6 +22,7 @@
2122
import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request;
2223

2324
import java.io.IOException;
25+
import java.nio.file.Files;
2426
import java.nio.file.Path;
2527

2628
import org.junit.jupiter.api.AfterAll;
@@ -29,6 +31,7 @@
2931
import org.junit.jupiter.api.Order;
3032
import org.junit.jupiter.api.Test;
3133
import org.junit.jupiter.api.TestMethodOrder;
34+
import org.junit.jupiter.api.condition.EnabledOnOs;
3235
import org.junit.jupiter.api.io.CleanupMode;
3336
import org.junit.jupiter.api.io.TempDir;
3437
import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
@@ -452,4 +455,57 @@ void testOnSuccessFailingParameter(@TempDir(cleanup = ON_SUCCESS) Path onSuccess
452455

453456
}
454457

458+
@Nested
459+
@EnabledOnOs(WINDOWS)
460+
class WindowsTests {
461+
462+
@Test
463+
void deletesBrokenJunctions(@TempDir Path dir) throws Exception {
464+
var test = Files.createDirectory(dir.resolve("test"));
465+
createWindowsJunction(dir.resolve("link"), test);
466+
// The error might also occur without the source folder being deleted
467+
// but it depends on the order that the TempDir cleanup does its work,
468+
// so the following line forces the error to occur always
469+
Files.delete(test);
470+
}
471+
472+
@Test
473+
void doesNotFollowJunctions(@TempDir Path tempDir) throws IOException {
474+
var outsideDir = Files.createDirectory(tempDir.resolve("outside"));
475+
var testFile = Files.writeString(outsideDir.resolve("test.txt"), "test");
476+
477+
JunctionTestCase.target = outsideDir;
478+
try {
479+
executeTestsForClass(JunctionTestCase.class).testEvents() //
480+
.assertStatistics(stats -> stats.started(1).succeeded(1));
481+
}
482+
finally {
483+
JunctionTestCase.target = null;
484+
}
485+
486+
assertThat(outsideDir).exists();
487+
assertThat(testFile).exists();
488+
}
489+
490+
@SuppressWarnings("JUnitMalformedDeclaration")
491+
static class JunctionTestCase {
492+
public static Path target;
493+
494+
@Test
495+
void createJunctionToTarget(@TempDir Path dir) throws Exception {
496+
var link = createWindowsJunction(dir.resolve("link"), target);
497+
try (var files = Files.list(link)) {
498+
files.forEach(it -> System.out.println("- " + it));
499+
}
500+
}
501+
}
502+
503+
private static Path createWindowsJunction(Path link, Path target) throws Exception {
504+
// This creates a Windows "junction" which you can't do with Java code
505+
String[] command = { "cmd.exe", "/c", "mklink", "/j", link.toString(), target.toString() };
506+
Runtime.getRuntime().exec(command).waitFor();
507+
return link;
508+
}
509+
}
510+
455511
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
junit.jupiter.extensions.autodetection.enabled=true
2+
3+
junit.platform.output.capture.stdout=true
4+
junit.platform.output.capture.stderr=true

jupiter-tests/src/test/resources/log4j2-test.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
<Logger name="org.junit.jupiter.engine" level="off" />
1212
<Logger name="org.junit.platform.launcher.listeners.discovery.LoggingLauncherDiscoveryListener" level="off" />
1313
<Logger name="org.junit.jupiter.engine.extension.MutableExtensionRegistry" level="off" />
14+
<Logger name="org.junit.jupiter.engine.extension.TempDirectory$CloseablePath" level="off" />
1415
<Root level="error">
1516
<AppenderRef ref="Console" />
1617
</Root>
1718
</Loggers>
18-
</Configuration>
19+
</Configuration>

0 commit comments

Comments
 (0)