This is an automated email from the ASF dual-hosted git repository.

bodewig pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ant.git

commit 45b84bb3c3384b771f446b26fbb6ebb0a97fd9c5
Author: Stefan Bodewig <[email protected]>
AuthorDate: Fri Feb 6 18:48:41 2026 +0100

    treat Windows junctions like symlinks in DirectoryScanner and <delete>
    
    https://bz.apache.org/bugzilla/show_bug.cgi?id=66293
---
 .../org/apache/tools/ant/DirectoryScanner.java     |  22 ++-
 src/main/org/apache/tools/ant/taskdefs/Delete.java |  17 +-
 .../tools/ant/types/selectors/TokenizedPath.java   |   8 +-
 src/main/org/apache/tools/ant/util/FileUtils.java  |  32 ++++
 .../antunit/core/dirscanner-junctions-test.xml     | 200 +++++++++++++++++++++
 .../antunit/taskdefs/delete-and-junctions-test.xml |  73 ++++++++
 6 files changed, 334 insertions(+), 18 deletions(-)

diff --git a/src/main/org/apache/tools/ant/DirectoryScanner.java 
b/src/main/org/apache/tools/ant/DirectoryScanner.java
index ff7b940c3..9d2103941 100644
--- a/src/main/org/apache/tools/ant/DirectoryScanner.java
+++ b/src/main/org/apache/tools/ant/DirectoryScanner.java
@@ -47,6 +47,7 @@ import org.apache.tools.ant.types.selectors.SelectorUtils;
 import org.apache.tools.ant.types.selectors.TokenizedPath;
 import org.apache.tools.ant.types.selectors.TokenizedPattern;
 import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.NtfsJunctionUtils;
 import org.apache.tools.ant.util.VectorSet;
 
 /**
@@ -228,6 +229,7 @@ public class DirectoryScanner
 
     /** Helper. */
     private static final FileUtils FILE_UTILS = FileUtils.getFileUtils();
+    private static final NtfsJunctionUtils JUNCTION_UTILS = 
NtfsJunctionUtils.getNtfsJunctionUtils();
 
     /**
      * Patterns which should be excluded by default.
@@ -869,7 +871,8 @@ public class DirectoryScanner
                 excludes = nullExcludes ? new String[0] : excludes;
 
                 if (basedir != null && !followSymlinks
-                    && Files.isSymbolicLink(basedir.toPath())) {
+                    && (Files.isSymbolicLink(basedir.toPath())
+                        || JUNCTION_UTILS.isDirectoryJunctionSafe(basedir))) {
                     notFollowedSymlinks.add(basedir.getAbsolutePath());
                     basedir = null;
                 }
@@ -956,7 +959,7 @@ public class DirectoryScanner
             File canonBase = null;
             if (basedir != null) {
                 try {
-                    canonBase = basedir.getCanonicalFile();
+                    canonBase = new File(FILE_UTILS.getResolvedPath(basedir));
                 } catch (final IOException ex) {
                     throw new BuildException(ex);
                 }
@@ -977,9 +980,10 @@ public class DirectoryScanner
                     // we need to double check.
                     try {
                         final String path = (basedir == null)
-                            ? myfile.getCanonicalPath()
-                            : FILE_UTILS.removeLeadingPath(canonBase,
-                                         myfile.getCanonicalFile());
+                            ? FILE_UTILS.getResolvedPath(myfile)
+                            : FILE_UTILS
+                                .removeLeadingPath(canonBase,
+                                                   new 
File(FILE_UTILS.getResolvedPath(myfile)));
                         if (!path.equals(currentelement) || ON_VMS) {
                             myfile = currentPath.findFile(basedir, true);
                             if (myfile != null && basedir != null) {
@@ -1216,7 +1220,7 @@ public class DirectoryScanner
                 final Path filePath = dir == null
                                         ? Paths.get(newFile)
                                         : dir.toPath().resolve(newFile);
-                if (Files.isSymbolicLink(filePath)) {
+                if (Files.isSymbolicLink(filePath) || 
JUNCTION_UTILS.isDirectoryJunctionSafe(filePath)) {
                     final String name = vpath + newFile;
                     final File file = new File(dir, newFile);
                     if (file.isDirectory()) {
@@ -1818,11 +1822,11 @@ public class DirectoryScanner
                                     : parent.toPath().resolve(dirName);
             if (directoryNamesFollowed.size() >= maxLevelsOfSymlinks
                 && Collections.frequency(directoryNamesFollowed, dirName) >= 
maxLevelsOfSymlinks
-                && Files.isSymbolicLink(dirPath)) {
+                && (Files.isSymbolicLink(dirPath) || 
JUNCTION_UTILS.isDirectoryJunction(dirPath))) {
 
                 final List<String> files = new ArrayList<>();
                 File f = FILE_UTILS.resolveFile(parent, dirName);
-                final String target = f.getCanonicalPath();
+                final String target = FILE_UTILS.getResolvedPath(f);
                 files.add(target);
 
                 StringBuilder relPath = new StringBuilder();
@@ -1830,7 +1834,7 @@ public class DirectoryScanner
                     relPath.append("../");
                     if (dirName.equals(dir)) {
                         f = FILE_UTILS.resolveFile(parent, relPath + dir);
-                        files.add(f.getCanonicalPath());
+                        files.add(FILE_UTILS.getResolvedPath(f));
                         if (files.size() > maxLevelsOfSymlinks
                             && Collections.frequency(files, target) > 
maxLevelsOfSymlinks) {
                             return true;
diff --git a/src/main/org/apache/tools/ant/taskdefs/Delete.java 
b/src/main/org/apache/tools/ant/taskdefs/Delete.java
index 665c36255..7b8882ac1 100644
--- a/src/main/org/apache/tools/ant/taskdefs/Delete.java
+++ b/src/main/org/apache/tools/ant/taskdefs/Delete.java
@@ -390,11 +390,12 @@ public class Delete extends MatchingTask {
     }
 
     /**
-     * Sets whether the symbolic links that have not been followed
-     * shall be removed (the links, not the locations they point at).
+     * Sets whether the symbolic links or Windows directory junctions
+     * that have not been followed shall be removed (the links, not
+     * the locations they point at).
      *
      * @param b boolean
-     * @since Ant 1.8.0
+     * @since Ant 1.8.0, support for junctions has been added with 1.10.16
      */
     public void setRemoveNotFollowedSymlinks(boolean b) {
         removeNotFollowedSymlinks = b;
@@ -641,7 +642,8 @@ public class Delete extends MatchingTask {
         // delete the single link
         if (link != null) {
             if (link.exists()) {
-                if (Files.isSymbolicLink(link.toPath()) || 
JUNCTION_UTILS.isDirectoryJunctionSafe(link)) {
+                if (Files.isSymbolicLink(link.toPath())
+                    || JUNCTION_UTILS.isDirectoryJunctionSafe(link)) {
                     log("Deleting: " + link.getAbsolutePath());
 
                     if (!delete(link)) {
@@ -742,8 +744,9 @@ public class Delete extends MatchingTask {
                         Arrays.sort(links, Comparator.reverseOrder());
                         for (String link : links) {
                             final Path filePath = Paths.get(link);
-                            if (!Files.isSymbolicLink(filePath)) {
-                                // it's not a symbolic link, so move on
+                            if (!Files.isSymbolicLink(filePath)
+                                && 
!JUNCTION_UTILS.isDirectoryJunctionSafe(filePath)) {
+                                // it's not a symbolic link or junction, so 
move on
                                 continue;
                             }
                             // it's a symbolic link, so delete it
@@ -905,7 +908,7 @@ public class Delete extends MatchingTask {
     }
 
     private boolean isDanglingSymlink(final File f) {
-        if (!Files.isSymbolicLink(f.toPath())) {
+        if (!Files.isSymbolicLink(f.toPath()) && 
!JUNCTION_UTILS.isDirectoryJunctionSafe(f)) {
             // it's not a symlink, so clearly it's not a dangling one
             return false;
         }
diff --git a/src/main/org/apache/tools/ant/types/selectors/TokenizedPath.java 
b/src/main/org/apache/tools/ant/types/selectors/TokenizedPath.java
index a92d7e488..b53920998 100644
--- a/src/main/org/apache/tools/ant/types/selectors/TokenizedPath.java
+++ b/src/main/org/apache/tools/ant/types/selectors/TokenizedPath.java
@@ -25,6 +25,7 @@ import java.nio.file.Paths;
 
 import org.apache.tools.ant.BuildException;
 import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.NtfsJunctionUtils;
 
 /**
  * Container for a path that has been split into its components.
@@ -32,6 +33,8 @@ import org.apache.tools.ant.util.FileUtils;
  */
 public class TokenizedPath {
 
+    private static final NtfsJunctionUtils JUNCTION_UTILS = 
NtfsJunctionUtils.getNtfsJunctionUtils();
+
     /**
      * Instance that holds no tokens at all.
      */
@@ -133,7 +136,7 @@ public class TokenizedPath {
     }
 
     /**
-     * Do we have to traverse a symlink when trying to reach path from
+     * Do we have to traverse a symlink or directory junction when trying to 
reach path from
      * basedir?
      * @param base base File (dir).
      * @return boolean
@@ -146,7 +149,8 @@ public class TokenizedPath {
             } else {
                 pathToTraverse = Paths.get(base.toPath().toString(), token);
             }
-            if (Files.isSymbolicLink(pathToTraverse)) {
+            if (Files.isSymbolicLink(pathToTraverse)
+                || JUNCTION_UTILS.isDirectoryJunctionSafe(pathToTraverse)) {
                 return true;
             }
             base = new File(base, token);
diff --git a/src/main/org/apache/tools/ant/util/FileUtils.java 
b/src/main/org/apache/tools/ant/util/FileUtils.java
index 824820233..03885145b 100644
--- a/src/main/org/apache/tools/ant/util/FileUtils.java
+++ b/src/main/org/apache/tools/ant/util/FileUtils.java
@@ -19,6 +19,7 @@ package org.apache.tools.ant.util;
 
 import java.io.File;
 import java.io.FilenameFilter;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -87,6 +88,9 @@ public class FileUtils {
     private static final boolean ON_WIN9X = Os.isFamily("win9x");
     private static final boolean ON_WINDOWS = Os.isFamily("windows");
 
+    private static final boolean CAN_TRUST_GET_CANONICAL_PATH =
+        !ON_WINDOWS || JavaEnvUtils.isAtLeastJavaVersion("24");
+
     static final int BUF_SIZE = 8192;
 
 
@@ -1991,4 +1995,32 @@ public class FileUtils {
     public String stripLeadingPathSeparator(String path) {
         return startsWithPathSeparator(path) ? path.substring(1) : path;
     }
+
+    /**
+     * Tries to get the canonical path of a file resolving symbolic
+     * links or Windows directory junctions.
+     *
+     * <p>On any platform other than Windows this simply invokes
+     * {@link File#getCanonicalPath()} - the same is true for Windows
+     * if the current Java VM is at least Java 24.</p>
+     *
+     * <p>Prior to Java 24 <q>getCanonicalPath</q> doesn't resolve
+     * symbolic links or junctions points on Windows, so the code will
+     * use {@link Path#toRealPath} instead - if the file or the file
+     * linked to exists. If the file or the file linked to doesn't
+     * exist the code falls back to {@link File#getCanonicalPath()}
+     * anyway.</p>
+     *
+     * @since Ant 1.10.16
+     */
+    public String getResolvedPath(File f) throws IOException {
+        if (!CAN_TRUST_GET_CANONICAL_PATH) {
+            try {
+                return f.toPath().toRealPath().toString();
+            } catch (FileNotFoundException ex) {
+                // file or link target doesn't exist, fall back to 
getCanonicalPath
+            }
+        }
+        return f.getCanonicalPath();
+    }
 }
diff --git a/src/tests/antunit/core/dirscanner-junctions-test.xml 
b/src/tests/antunit/core/dirscanner-junctions-test.xml
new file mode 100644
index 000000000..5f23e7285
--- /dev/null
+++ b/src/tests/antunit/core/dirscanner-junctions-test.xml
@@ -0,0 +1,200 @@
+<?xml version="1.0"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns:au="antlib:org.apache.ant.antunit" default="antunit">
+
+  <import file="../antunit-base.xml"/>
+
+  <target name="setUp">
+    <property name="base" location="${input}/base"/>
+    <mkdir dir="${base}"/>
+  </target>
+
+  <target name="checkOs">
+    <condition property="windows"><os family="windows"/></condition>
+  </target>
+
+  <macrodef name="assertDirIsEmpty">
+    <attribute name="dir" default="${output}"/>
+    <sequential>
+      <local name="resources"/>
+      <resourcecount property="resources">
+        <fileset dir="@{dir}"/>
+      </resourcecount>
+      <au:assertEquals expected="0" actual="${resources}"/>
+    </sequential>
+  </macrodef>
+
+  <target name="testJunctionToSiblingFollow"
+          depends="checkOs, setUp, -sibling"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true"/>
+    </copy>
+    <au:assertFileExists file="${output}/B/file.txt"/>
+  </target>
+
+  <target name="testJunctionToSiblingNoFollow"
+          depends="checkOs, setUp, -sibling"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="false"/>
+    </copy>
+    <au:assertFileDoesntExist file="${output}/B/file.txt"/>
+  </target>
+
+  <target name="testBasedirIsJunctionFollow"
+          depends="checkOs, setUp, -basedir-as-junction"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true"/>
+    </copy>
+    <au:assertFileExists file="${output}/file.txt"/>
+  </target>
+
+  <target name="testBasedirIsJunctionNoFollow"
+          depends="checkOs, setUp, -basedir-as-junction"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="false"/>
+    </copy>
+    <au:assertFileDoesntExist file="${output}/file.txt"/>
+  </target>
+
+  <target name="testLinkToParentFollow"
+          depends="checkOs, setUp, -link-to-parent"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true" maxLevelsOfSymlinks="1"/>
+    </copy>
+    <delete link="${base}/A"/>
+    <au:assertFileExists file="${output}/A/B/file.txt"/>
+    <au:assertFileDoesntExist file="${output}/A/base/A/B/file.txt"/>
+  </target>
+
+  <target name="testLinkToParentFollowMax2"
+          depends="checkOs, setUp, -link-to-parent"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true" maxLevelsOfSymlinks="2"/>
+    </copy>
+    <delete link="${base}/A"/>
+    <au:assertFileExists file="${output}/A/B/file.txt"/>
+    <au:assertFileExists file="${output}/A/base/A/B/file.txt"/>
+  </target>
+
+  <target name="testLinkToParentFollowWithInclude"
+          depends="checkOs, setUp, -link-to-parent"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true">
+        <include name="A/B/*"/>
+      </fileset>
+    </copy>
+    <delete link="${base}/A"/>
+    <au:assertFileExists file="${output}/A/B/file.txt"/>
+  </target>
+
+  <!-- supposed to fail? -->
+  <target name="testLinkToParentFollowWithIncludeMultiFollow"
+          depends="checkOs, setUp, -link-to-parent"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true">
+        <include name="A/base/A/B/*"/>
+      </fileset>
+    </copy>
+    <delete link="${base}/A"/>
+    <au:assertFileExists file="${output}/A/base/A/B/file.txt"/>
+  </target>
+
+  <target name="testLinkToParentNoFollow"
+          depends="checkOs, setUp, -link-to-parent"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="false"/>
+    </copy>
+    <delete link="${base}/A"/>
+    <au:assertFileDoesntExist file="${output}/A/B/file.txt"/>
+  </target>
+
+  <target name="testSillyLoopFollow"
+          depends="checkOs, setUp, -silly-loop"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true"/>
+    </copy>
+    <delete link="${base}"/>
+    <assertDirIsEmpty/>
+  </target>
+
+  <target name="testSillyLoopNoFollow"
+          depends="checkOs, setUp, -silly-loop"
+          if="windows">
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="false"/>
+    </copy>
+    <delete link="${base}"/>
+    <au:assertFileDoesntExist file="${output}"/>
+  </target>
+
+  <target name="testRepeatedName"
+          depends="setUp">
+    <mkdir dir="${base}/A/A/A/A"/>
+    <touch file="${base}/A/A/A/A/file.txt"/>
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true" maxLevelsOfSymlinks="1"/>
+    </copy>
+    <au:assertFileExists file="${output}/A/A/A/A/file.txt"/>
+  </target>
+
+  <target name="testRepeatedNameWithLinkButNoLoop"
+          depends="checkOs, setUp"
+          if="windows">
+    <mkdir dir="${base}/A/A/A/B"/>
+    <touch file="${base}/A/A/A/B/file.txt"/>
+    <mklink linktype="junction" link="${base}/A/A/A/A" 
targetFile="${base}/A/A/A/B"/>
+    <copy todir="${output}">
+      <fileset dir="${base}" followsymlinks="true" maxLevelsOfSymlinks="1"/>
+    </copy>
+    <au:assertFileExists file="${output}/A/A/A/A/file.txt"/>
+  </target>
+
+  <target name="-sibling" if="windows">
+    <mkdir dir="${base}/A"/>
+    <touch file="${base}/A/file.txt"/>
+    <mklink linktype="junction" link="${base}/B" targetFile="${base}/A"/>
+  </target>
+
+  <target name="-basedir-as-junction" if="windows">
+    <delete dir="${base}"/>
+    <mkdir dir="${input}/realdir"/>
+    <touch file="${input}/realdir/file.txt"/>
+    <mklink linktype="junction" link="${base}" targetFile="${input}/realdir"/>
+  </target>
+
+  <target name="-link-to-parent" if="windows">
+    <mkdir dir="${input}/B"/>
+    <touch file="${input}/B/file.txt"/>
+    <mklink linktype="junction" link="${base}/A" targetFile="${input}"/>
+  </target>
+
+  <target name="-silly-loop" if="windows">
+    <delete dir="${base}"/>
+    <mklink linktype="junction" link="${base}" targetFile="${input}"/>
+  </target>
+</project>
diff --git a/src/tests/antunit/taskdefs/delete-and-junctions-test.xml 
b/src/tests/antunit/taskdefs/delete-and-junctions-test.xml
new file mode 100644
index 000000000..c0fc54a9a
--- /dev/null
+++ b/src/tests/antunit/taskdefs/delete-and-junctions-test.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<project name="delete-test" basedir="." default="antunit"
+         xmlns:au="antlib:org.apache.ant.antunit">
+
+  <import file="../antunit-base.xml" />
+
+  <condition property="windows">
+    <os family="windows" />
+  </condition>
+
+  <target name="tearDown" depends="removelink, antunit-base.tearDown"/>
+
+  <target name="removelink" if="link">
+    <delete link="${link}"/>
+  </target>
+
+  <target name="setUp" if="windows">
+    <mkdir dir="${input}/A/B"/>
+    <mkdir dir="${input}/C"/>
+    <property name="link" location="${input}/A/B/C"/>
+    <mklink linktype="junction" link="${link}" targetfile="${input}/C"/>
+  </target>
+
+  <target name="testNotFollowedLink" if="windows" depends="setUp">
+    <delete>
+      <fileset dir="${input}" followSymlinks="false"/>
+    </delete>
+    <au:assertFileExists file="${input}/A/B/C"/>
+  </target>
+
+  <target name="testRemoveNotFollowedLink" if="windows" depends="setUp">
+    <delete removeNotFollowedSymlinks="true">
+      <fileset dir="${input}/A" followSymlinks="false"/>
+    </delete>
+    <au:assertFileDoesntExist file="${input}/A/B/C"/>
+    <au:assertFileExists file="${input}/C"/>
+  </target>
+
+  <target name="testRemoveNotFollowedLinkDeletesNotIncludedDirs"
+          depends="setUp" if="windows"
+          
description="https://issues.apache.org/bugzilla/show_bug.cgi?id=53959";>
+    <delete removeNotFollowedSymlinks="true">
+      <fileset dir="${input}/A" followSymlinks="false" includes="**/D"/>
+    </delete>
+    <au:assertFileDoesntExist file="${input}/A/B/C"/>
+    <au:assertFileExists file="${input}/C"/>
+  </target>
+
+  <target name="testRemoveNotFollowedLinkHonorsExcludes" if="windows"
+          depends="setUp">
+    <delete removeNotFollowedSymlinks="true">
+      <fileset dir="${input}/A" followSymlinks="false" excludes="**/C/**"/>
+    </delete>
+    <au:assertFileExists file="${input}/A/B/C"/>
+  </target>
+</project>

Reply via email to