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>
