You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/26 17:09:36 UTC

(maven-filtering) branch master updated: fix: handle symlinks in source and destination for copyFile (MSHARED-1004)

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

gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-filtering.git


The following commit(s) were added to refs/heads/master by this push:
     new b33d17a  fix: handle symlinks in source and destination for copyFile (MSHARED-1004)
b33d17a is described below

commit b33d17a853bec1241561498a84a20f2896f12d3a
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Sat Sep 26 19:09:30 2026 +0200

    fix: handle symlinks in source and destination for copyFile (MSHARED-1004)
    
    - Detect symlink at destination in FilteringUtils.copyFile and delete it before opening for write
    - Ensures output is always a regular file, preventing FileAlreadyExistsException and NoSuchFileException on incremental builds
    - Files.isSymbolicLink() is safe for non-existent paths (returns false), so this is safe for first-write case too
    - Fixes #248 (MSHARED-1004)
---
 .../maven/shared/filtering/FilteringUtils.java     |  7 ++
 .../filtering/DefaultMavenFileFilterTest.java      | 55 ++++++++++++++
 .../maven/shared/filtering/FilteringUtilsTest.java | 83 ++++++++++++++++++++++
 3 files changed, 145 insertions(+)

diff --git a/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java b/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
index 732eae0..d8fcc8d 100644
--- a/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
+++ b/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
@@ -298,6 +298,13 @@ public final class FilteringUtils {
      */
     public static void copyFile(Path from, Path to, String encoding, FilterWrapper[] wrappers, boolean overwrite)
             throws IOException {
+        // If the destination is a symbolic link (dangling or pointing to a regular file left by an
+        // earlier build that used NOFOLLOW_LINKS), delete it before writing so that the output is
+        // always a regular file and CachingOutputStream / CachingWriter do not fail with
+        // NoSuchFileException when trying to follow a dangling link.
+        if (Files.isSymbolicLink(to)) {
+            Files.delete(to);
+        }
         if (wrappers == null || wrappers.length == 0) {
             try (OutputStream os = new CachingOutputStream(to)) {
                 Files.copy(from, os);
diff --git a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java
index 0be2ce7..77aae53 100644
--- a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java
+++ b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java
@@ -40,12 +40,15 @@ import org.apache.maven.di.Injector;
 import org.codehaus.plexus.interpolation.AbstractValueSource;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.api.io.TempDir;
 import org.slf4j.Logger;
 import org.sonatype.plexus.build.incremental.BuildContext;
 
 import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
@@ -244,4 +247,56 @@ class DefaultMavenFileFilterTest {
             assertEquals("toto@titi.com bar", IOUtils.toString(reader));
         }
     }
+
+    /**
+     * MSHARED-1004: a file that is a symbolic link in the source directory must be followed.
+     * Its content must be written as a regular file at the destination, not as a symlink.
+     * On a second invocation the regular file must be silently overwritten (no FileAlreadyExistsException).
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void copyFileSymlinkSourceIsFollowed() throws Exception {
+        Path realFile = tempDir.resolve("real.properties");
+        Files.writeString(realFile, "key=value");
+
+        // symlink that lives in the same directory as the real file (relative target)
+        Path symlink = tempDir.resolve("link.properties");
+        Files.createSymbolicLink(symlink, realFile.getFileName());
+
+        Path toFile = tempDir.resolve("output.properties");
+
+        MavenFileFilter mavenFileFilter = container.getInstance(MavenFileFilter.class);
+
+        // First copy
+        mavenFileFilter.copyFile(symlink, toFile, false, null, null);
+        assertFalse(Files.isSymbolicLink(toFile), "output must be a regular file, not a symlink");
+        assertEquals("key=value", Files.readString(toFile));
+
+        // Second copy must not throw FileAlreadyExistsException or NoSuchFileException
+        mavenFileFilter.copyFile(symlink, toFile, false, null, null);
+        assertFalse(Files.isSymbolicLink(toFile));
+        assertEquals("key=value", Files.readString(toFile));
+    }
+
+    /**
+     * MSHARED-1004: when the destination already is a dangling symbolic link (left by an older
+     * version of maven-filtering that used NOFOLLOW_LINKS), copyFile must replace it with a regular
+     * file without throwing.
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void copyFileReplacesDanglingSymlinkAtDestination() throws Exception {
+        Path realFile = tempDir.resolve("real.txt");
+        Files.writeString(realFile, "content");
+
+        Path toFile = tempDir.resolve("output.txt");
+        // Simulate a dangling symlink at the destination (as written by old NOFOLLOW_LINKS code)
+        Files.createSymbolicLink(toFile, Path.of("../nonexistent/target.txt"));
+
+        MavenFileFilter mavenFileFilter = container.getInstance(MavenFileFilter.class);
+        mavenFileFilter.copyFile(realFile, toFile, false, null, null);
+
+        assertFalse(Files.isSymbolicLink(toFile), "dangling symlink must be replaced by a regular file");
+        assertEquals("content", Files.readString(toFile));
+    }
 }
diff --git a/src/test/java/org/apache/maven/shared/filtering/FilteringUtilsTest.java b/src/test/java/org/apache/maven/shared/filtering/FilteringUtilsTest.java
index a83a68d..cf2b1fd 100644
--- a/src/test/java/org/apache/maven/shared/filtering/FilteringUtilsTest.java
+++ b/src/test/java/org/apache/maven/shared/filtering/FilteringUtilsTest.java
@@ -26,9 +26,13 @@ import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
 
 import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 
 /**
  * @author John Casey
@@ -39,6 +43,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 class FilteringUtilsTest {
     private static final Path TEST_DIRECTORY = Paths.get(getBasedir(), "target/test-classes/");
 
+    @TempDir
+    Path tempDir;
+
     @Test
     void mshared1213CopyWithTargetAlreadyExisting0ByteFile() throws Exception {
         Path fromFile = Paths.get(getBasedir() + "/src/test/units-files/MSHARED-1213/enunciate.xml");
@@ -164,4 +171,80 @@ class FilteringUtilsTest {
         assertEquals("../../bin", FilteringUtils.getRelativeFilePath("/usr/local/", "/bin"));
         assertEquals("../usr/local/", FilteringUtils.getRelativeFilePath("/bin", "/usr/local/"));
     }
+
+    // MSHARED-1004: symbolic links in resources
+
+    /**
+     * A file-symlink in the source directory must be followed: the target file's content is
+     * copied to the destination as a regular file (not as a symlink).
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void copyFileSymlinkIsFollowedAndWrittenAsRegularFile() throws Exception {
+        // real file
+        Path realFile = tempDir.resolve("real.txt");
+        Files.writeString(realFile, "symlink content");
+
+        // symlink → real file
+        Path symlink = tempDir.resolve("link.txt");
+        Files.createSymbolicLink(symlink, realFile.getFileName()); // relative: ../real.txt
+
+        Path toFile = tempDir.resolve("output.txt");
+        FilteringUtils.copyFile(symlink, toFile, "UTF-8", new FilterWrapper[0], false);
+
+        assertFalse(Files.isSymbolicLink(toFile), "destination must be a regular file, not a symlink");
+        assertEquals("symlink content", Files.readString(toFile));
+    }
+
+    /**
+     * When the destination is a dangling symlink left by a previous build (e.g. from an older
+     * version of maven-filtering that copied symlinks verbatim), copyFile must replace it with a
+     * regular file instead of failing with NoSuchFileException.
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void copyFileReplacesDanglingSymlinkAtDestination() throws Exception {
+        Path realFile = tempDir.resolve("real.txt");
+        Files.writeString(realFile, "hello");
+
+        Path toFile = tempDir.resolve("output.txt");
+        // Simulate a dangling symlink left by old code
+        Files.createSymbolicLink(toFile, Path.of("../nonexistent/B"));
+
+        // Must succeed even though the symlink is dangling
+        FilteringUtils.copyFile(realFile, toFile, "UTF-8", new FilterWrapper[0], false);
+
+        assertFalse(Files.isSymbolicLink(toFile), "dangling symlink must be replaced by a regular file");
+        assertEquals("hello", Files.readString(toFile));
+    }
+
+    /**
+     * Same as above but with filtering active (wrappers path through CachingWriter).
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void copyFileWithFilteringReplacesDanglingSymlinkAtDestination() throws Exception {
+        Path realFile = tempDir.resolve("real.txt");
+        Files.writeString(realFile, "hello");
+
+        Path toFile = tempDir.resolve("output.txt");
+        Files.createSymbolicLink(toFile, Path.of("../nonexistent/B"));
+
+        FilteringUtils.copyFile(
+                realFile,
+                toFile,
+                "UTF-8",
+                new FilterWrapper[] {
+                    new FilterWrapper() {
+                        @Override
+                        public Reader getReader(Reader fileReader) {
+                            return fileReader;
+                        }
+                    }
+                },
+                false);
+
+        assertFalse(Files.isSymbolicLink(toFile), "dangling symlink must be replaced by a regular file");
+        assertEquals("hello", Files.readString(toFile));
+    }
 }