From 2cd6c8a37145517e14f3d625ede514e7681c56ed Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 10 Oct 2026 12:30:45 +0000 Subject: [PATCH 1/5] feat: use hard links for artifact installation by default Add `aether.installer.hardLink` configuration property (default `true`) that makes `DefaultInstaller` create a hard link from the build output to the local repository instead of copying the file. This avoids redundant I/O for large JARs built locally. When hard linking is not possible (cross-device, unsupported filesystem), the installer automatically falls back to a regular file copy so the build is never broken. The property can be set to `false` to opt out and always use copy. Co-authored-by: Claude Sonnet 4.6 --- .../aether/ConfigurationProperties.java | 37 +++++++++++++++ .../internal/impl/DefaultInstaller.java | 25 ++++++++++- .../internal/impl/DefaultInstallerTest.java | 45 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java index b8d676c95b..477c8dec35 100644 --- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java +++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java @@ -88,6 +88,13 @@ public final class ConfigurationProperties { */ public static final String PREFIX_UTIL = PREFIX_AETHER + "util."; + /** + * Prefix for installer related configurations. For internal use only. + * + * @since 2.0.9 + */ + public static final String PREFIX_INSTALLER = PREFIX_AETHER + "installer."; + /** * Prefix for transport related configurations. For internal use only. * @@ -689,6 +696,36 @@ public enum HttpVersion { */ public static final boolean DEFAULT_HTTP_SEND_RFC9457_ACCEPT = true; + /** + * A flag indicating whether artifact installation should use hard links instead of copying files. When enabled, + * the installer creates a hard link from the source artifact (build output) to its destination in the local + * repository, avoiding a full file copy and saving disk I/O and space for large JARs. + *

+ * Hard links require that the source and target paths reside on the same filesystem volume. + * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM throws + * {@link java.nio.file.UnsupportedOperationException} or {@link java.nio.file.AtomicMoveNotSupportedException} + * and the installer automatically falls back to a regular file copy, so the build is never broken. + *

+ * Note: hard links share an inode. Any tool that overwrites the build output file + * in-place (rather than via an atomic rename) would silently corrupt the cached artifact. + * Standard Maven build tooling always replaces output via rename, so this is generally safe. Set this + * property to {@code false} if your environment performs in-place writes to build output files. + * + * @since 2.0.9 + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.Boolean} + * @configurationDefaultValue {@link #DEFAULT_INSTALLER_HARD_LINK} + * @configurationRepoIdSuffix No + */ + public static final String INSTALLER_HARD_LINK = PREFIX_INSTALLER + "hardLink"; + + /** + * The default value for {@link #INSTALLER_HARD_LINK}: {@code true}. + * + * @since 2.0.9 + */ + public static final boolean DEFAULT_INSTALLER_HARD_LINK = true; + private ConfigurationProperties() { // hide constructor } diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java index 5533c480af..b43bf40501 100644 --- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java +++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java @@ -22,6 +22,8 @@ import javax.inject.Named; import javax.inject.Singleton; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; @@ -32,6 +34,7 @@ import java.util.ListIterator; import java.util.Map; +import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.RepositoryEvent; import org.eclipse.aether.RepositoryEvent.EventType; import org.eclipse.aether.RepositorySystemSession; @@ -55,6 +58,7 @@ import org.eclipse.aether.spi.artifact.transformer.ArtifactTransformer; import org.eclipse.aether.spi.io.PathProcessor; import org.eclipse.aether.spi.synccontext.SyncContextFactory; +import org.eclipse.aether.util.ConfigUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -205,7 +209,26 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa throw new IllegalStateException("cannot install " + dstPath + " to same path"); } - pathProcessor.copyWithTimestamp(srcPath, dstPath); + boolean hardLink = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK, + ConfigurationProperties.INSTALLER_HARD_LINK); + if (hardLink) { + try { + Files.createDirectories(dstPath.getParent()); + if (Files.isDirectory(dstPath)) { + throw new IOException("destination is a directory: " + dstPath); + } + Files.deleteIfExists(dstPath); + Files.createLink(dstPath, srcPath); + } catch (UnsupportedOperationException | IOException e) { + // cross-device link (EXDEV) or unsupported FS — fall back to copy + logger.debug("Hard link not supported for {}, falling back to copy: {}", dstPath, e.getMessage()); + pathProcessor.copyWithTimestamp(srcPath, dstPath); + } + } else { + pathProcessor.copyWithTimestamp(srcPath, dstPath); + } lrm.add(session, new LocalArtifactRegistration(artifact)); } catch (Exception e) { exception = e; diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java index e6fa5e84f5..e01367a64e 100644 --- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java +++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java @@ -20,10 +20,12 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.List; +import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositoryEvent; import org.eclipse.aether.RepositoryEvent.EventType; @@ -377,4 +379,47 @@ void testSetArtifactTimestamps() throws InstallationException { localArtifactFile.lastModified(), "artifact timestamp was not set to src file"); } + + @Test + void testHardLinkInstallCreatesHardLink() throws InstallationException, IOException { + // default: hardLink=true — installer attempts a hard link, falls back to copy on cross-device + request.addArtifact(artifact); + installer.install(session, request); + + assertTrue(localArtifactFile.exists()); + assertEquals("artifact", TestFileUtils.readString(localArtifactFile)); + + // On same-device filesystems, verify a hard link was created (same inode). + // On cross-device (common in CI containers where /tmp and project dir are on different mounts), + // Files.createLink throws IOException and the installer falls back to copy — both are correct. + Path srcPath = artifact.getFile().toPath(); + Path dstPath = localArtifactFile.toPath(); + try { + boolean sameDevice = + Files.getAttribute(srcPath, "unix:dev").equals(Files.getAttribute(dstPath, "unix:dev")); + if (sameDevice) { + // hard link must have been created — same inode expected + assertEquals( + Files.getAttribute(srcPath, "unix:ino"), + Files.getAttribute(dstPath, "unix:ino"), + "installed artifact should be a hard link to the source on same-device FS"); + } + // cross-device: fallback to copy is correct — content already asserted above + } catch (UnsupportedOperationException | IllegalArgumentException ignored) { + // non-POSIX filesystem — inode/device check not available; content assertion is sufficient + } + } + + @Test + void testHardLinkDisabledFallsThroughToCopy() throws InstallationException, IOException { + // set hardLink=false — installer must copy, not hard-link + session.setConfigProperty(ConfigurationProperties.INSTALLER_HARD_LINK, false); + request.addArtifact(artifact); + installer.install(session, request); + + assertTrue(localArtifactFile.exists()); + assertEquals("artifact", TestFileUtils.readString(localArtifactFile)); + // With copy the content is correct; we cannot assert different inodes portably + // but we verify the install succeeds and produces the correct content + } } From ae08e0b92a6a79d281b290a127d6d015c7c97100 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 10 Oct 2026 14:00:03 +0000 Subject: [PATCH 2/5] fix: fall back to copy when hard link fails with IllegalArgumentException (Jimfs/provider mismatch) --- .../java/org/eclipse/aether/ConfigurationProperties.java | 5 +++-- .../org/eclipse/aether/internal/impl/DefaultInstaller.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java index 477c8dec35..7707b581d3 100644 --- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java +++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java @@ -702,8 +702,9 @@ public enum HttpVersion { * repository, avoiding a full file copy and saving disk I/O and space for large JARs. *

* Hard links require that the source and target paths reside on the same filesystem volume. - * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM throws - * {@link java.nio.file.UnsupportedOperationException} or {@link java.nio.file.AtomicMoveNotSupportedException} + * When they do not (e.g. {@code ~/.m2} is on a separate mount, or a custom in-memory filesystem such as + * Jimfs is used for testing), the JVM throws {@link java.nio.file.UnsupportedOperationException}, + * {@link IllegalArgumentException} (provider mismatch), or {@link java.io.IOException} (cross-device), * and the installer automatically falls back to a regular file copy, so the build is never broken. *

* Note: hard links share an inode. Any tool that overwrites the build output file diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java index b43bf40501..b5c5326609 100644 --- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java +++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java @@ -221,8 +221,8 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa } Files.deleteIfExists(dstPath); Files.createLink(dstPath, srcPath); - } catch (UnsupportedOperationException | IOException e) { - // cross-device link (EXDEV) or unsupported FS — fall back to copy + } catch (UnsupportedOperationException | IllegalArgumentException | IOException e) { + // cross-device link (EXDEV), provider mismatch (e.g. Jimfs), or unsupported FS — fall back to copy logger.debug("Hard link not supported for {}, falling back to copy: {}", dstPath, e.getMessage()); pathProcessor.copyWithTimestamp(srcPath, dstPath); } From 9cfecea5c81e7fa913ba1981a3fc3677de1f1215 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 10 Oct 2026 17:21:44 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20@since=20tags,=20IOException=20guard,=20config=20ho?= =?UTF-8?q?isting,=20log=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix @since 2.0.9 → @since 2.0.25 on PREFIX_INSTALLER, INSTALLER_HARD_LINK, DEFAULT_INSTALLER_HARD_LINK - Move isDirectory check outside the inner try/catch so IOException propagates correctly instead of being silently swallowed as a 'hard link not supported' fallback - Hoist ConfigUtils.getBoolean() to install(SyncContext,session,request) so it is read once per request instead of once per artifact - Upgrade fallback log from DEBUG to WARN so cross-device link fallback is visible --- .../aether/ConfigurationProperties.java | 6 ++--- .../internal/impl/DefaultInstaller.java | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java index 7707b581d3..91aaff4aa9 100644 --- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java +++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java @@ -91,7 +91,7 @@ public final class ConfigurationProperties { /** * Prefix for installer related configurations. For internal use only. * - * @since 2.0.9 + * @since 2.0.25 */ public static final String PREFIX_INSTALLER = PREFIX_AETHER + "installer."; @@ -712,7 +712,7 @@ public enum HttpVersion { * Standard Maven build tooling always replaces output via rename, so this is generally safe. Set this * property to {@code false} if your environment performs in-place writes to build output files. * - * @since 2.0.9 + * @since 2.0.25 * @configurationSource {@link RepositorySystemSession#getConfigProperties()} * @configurationType {@link java.lang.Boolean} * @configurationDefaultValue {@link #DEFAULT_INSTALLER_HARD_LINK} @@ -723,7 +723,7 @@ public enum HttpVersion { /** * The default value for {@link #INSTALLER_HARD_LINK}: {@code true}. * - * @since 2.0.9 + * @since 2.0.25 */ public static final boolean DEFAULT_INSTALLER_HARD_LINK = true; diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java index b5c5326609..01a4a12953 100644 --- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java +++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java @@ -151,6 +151,11 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s result.addMetadata(metadata); } + boolean hardLink = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK, + ConfigurationProperties.INSTALLER_HARD_LINK); + for (ListIterator iterator = artifacts.listIterator(); iterator.hasNext(); ) { Artifact artifact = iterator.next(); @@ -160,7 +165,7 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s iterator.set(artifact); - install(session, trace, artifact); + install(session, trace, artifact, hardLink); if (artifact.getProperty(ArtifactGeneratorFactory.ARTIFACT_GENERATOR_ID, null) == null) { result.addArtifact(artifact); } @@ -195,7 +200,7 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s } } - private void install(RepositorySystemSession session, RequestTrace trace, Artifact artifact) + private void install(RepositorySystemSession session, RequestTrace trace, Artifact artifact, boolean hardLink) throws InstallationException { final LocalRepositoryManager lrm = session.getLocalRepositoryManager(); final Path srcPath = artifact.getPath(); @@ -209,21 +214,17 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa throw new IllegalStateException("cannot install " + dstPath + " to same path"); } - boolean hardLink = ConfigUtils.getBoolean( - session, - ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK, - ConfigurationProperties.INSTALLER_HARD_LINK); if (hardLink) { + Files.createDirectories(dstPath.getParent()); + if (Files.isDirectory(dstPath)) { + throw new IOException("destination is a directory: " + dstPath); + } try { - Files.createDirectories(dstPath.getParent()); - if (Files.isDirectory(dstPath)) { - throw new IOException("destination is a directory: " + dstPath); - } Files.deleteIfExists(dstPath); Files.createLink(dstPath, srcPath); } catch (UnsupportedOperationException | IllegalArgumentException | IOException e) { // cross-device link (EXDEV), provider mismatch (e.g. Jimfs), or unsupported FS — fall back to copy - logger.debug("Hard link not supported for {}, falling back to copy: {}", dstPath, e.getMessage()); + logger.warn("Hard link not supported for {}, falling back to copy: {}", dstPath, e.getMessage()); pathProcessor.copyWithTimestamp(srcPath, dstPath); } } else { From 5fdca380b1662ddf0fd22d372f9ec67199873494 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 13 Oct 2026 12:32:57 +0000 Subject: [PATCH 4/5] fix: log hard-link fallback once per install request, add counting assertions to tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace per-artifact logger.warn with accumulate-then-log pattern: collect first link failure in outer loop, addSuppressed() for subsequent ones, log once at WARN after all artifacts are processed — avoids N WARN lines in multi-module builds where ~/.m2 is on a different mount - Change private install(..., hardLink) return type from void to Exception (the link failure, null on success) so the outer loop can collect failures - Add CountingPathProcessor in tests: counts copyWithTimestamp invocations, allowing testHardLinkInstallCreatesHardLink to assert copy was NOT called on same-device FS and WAS called on cross-device, and testHardLinkDisabledFallsThroughToCopy to assert copy IS called when hardLink=false (addresses review feedback on test coverage gaps) --- .../internal/impl/DefaultInstaller.java | 25 +++++++-- .../internal/impl/DefaultInstallerTest.java | 53 +++++++++++++++++-- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java index 01a4a12953..0a02e8289a 100644 --- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java +++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java @@ -156,6 +156,7 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK, ConfigurationProperties.INSTALLER_HARD_LINK); + Exception linkFailure = null; for (ListIterator iterator = artifacts.listIterator(); iterator.hasNext(); ) { Artifact artifact = iterator.next(); @@ -165,11 +166,22 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s iterator.set(artifact); - install(session, trace, artifact, hardLink); + Exception failure = install(session, trace, artifact, hardLink); + if (failure != null && linkFailure == null) { + linkFailure = failure; + } else if (failure != null) { + linkFailure.addSuppressed(failure); + } if (artifact.getProperty(ArtifactGeneratorFactory.ARTIFACT_GENERATOR_ID, null) == null) { result.addArtifact(artifact); } } + if (linkFailure != null) { + logger.warn( + "Hard link not supported, falling back to copy for all artifacts: {}", + linkFailure.getMessage(), + linkFailure); + } metadatas = Utils.finishMetadata(metadataGenerators, artifacts); @@ -200,7 +212,12 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s } } - private void install(RepositorySystemSession session, RequestTrace trace, Artifact artifact, boolean hardLink) + /** + * Installs a single artifact. Returns the link failure exception if hard-linking was attempted but failed + * (caller logs it once after the loop), or {@code null} if the artifact was installed successfully via hard link + * or if {@code hardLink} is {@code false}. + */ + private Exception install(RepositorySystemSession session, RequestTrace trace, Artifact artifact, boolean hardLink) throws InstallationException { final LocalRepositoryManager lrm = session.getLocalRepositoryManager(); final Path srcPath = artifact.getPath(); @@ -208,6 +225,7 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa artifactInstalling(session, trace, artifact, dstPath); + Exception linkFailure = null; Exception exception = null; try { if (dstPath.equals(srcPath)) { @@ -224,7 +242,7 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa Files.createLink(dstPath, srcPath); } catch (UnsupportedOperationException | IllegalArgumentException | IOException e) { // cross-device link (EXDEV), provider mismatch (e.g. Jimfs), or unsupported FS — fall back to copy - logger.warn("Hard link not supported for {}, falling back to copy: {}", dstPath, e.getMessage()); + linkFailure = e; pathProcessor.copyWithTimestamp(srcPath, dstPath); } } else { @@ -237,6 +255,7 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa } finally { artifactInstalled(session, trace, artifact, dstPath, exception); } + return linkFailure; } private void install(RepositorySystemSession session, RequestTrace trace, Metadata metadata) diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java index e01367a64e..92f6fec706 100644 --- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java +++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java @@ -383,13 +383,26 @@ void testSetArtifactTimestamps() throws InstallationException { @Test void testHardLinkInstallCreatesHardLink() throws InstallationException, IOException { // default: hardLink=true — installer attempts a hard link, falls back to copy on cross-device + // + // Use a counting PathProcessor to detect whether the fallback copy path was taken. + // On same-device filesystems: hard link is created (copyWithTimestamp NOT called). + // On cross-device (common in CI containers): Files.createLink throws, copy IS taken. + CountingPathProcessor countingProcessor = new CountingPathProcessor(); + installer = new DefaultInstaller( + countingProcessor, + new StubRepositoryEventDispatcher(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + new StubSyncContextFactory()); + request.addArtifact(artifact); installer.install(session, request); assertTrue(localArtifactFile.exists()); assertEquals("artifact", TestFileUtils.readString(localArtifactFile)); - // On same-device filesystems, verify a hard link was created (same inode). + // On same-device filesystems, verify a hard link was created (same inode, no copy). // On cross-device (common in CI containers where /tmp and project dir are on different mounts), // Files.createLink throws IOException and the installer falls back to copy — both are correct. Path srcPath = artifact.getFile().toPath(); @@ -398,13 +411,22 @@ void testHardLinkInstallCreatesHardLink() throws InstallationException, IOExcept boolean sameDevice = Files.getAttribute(srcPath, "unix:dev").equals(Files.getAttribute(dstPath, "unix:dev")); if (sameDevice) { - // hard link must have been created — same inode expected + // hard link must have been created — same inode expected, copy NOT taken assertEquals( Files.getAttribute(srcPath, "unix:ino"), Files.getAttribute(dstPath, "unix:ino"), "installed artifact should be a hard link to the source on same-device FS"); + assertEquals( + 0, + countingProcessor.copyWithTimestampCount, + "copyWithTimestamp must NOT be called on same-device FS"); + } else { + // cross-device: fallback to copy must have been taken + assertEquals( + 1, + countingProcessor.copyWithTimestampCount, + "copyWithTimestamp must be called as fallback on cross-device FS"); } - // cross-device: fallback to copy is correct — content already asserted above } catch (UnsupportedOperationException | IllegalArgumentException ignored) { // non-POSIX filesystem — inode/device check not available; content assertion is sufficient } @@ -413,13 +435,34 @@ void testHardLinkInstallCreatesHardLink() throws InstallationException, IOExcept @Test void testHardLinkDisabledFallsThroughToCopy() throws InstallationException, IOException { // set hardLink=false — installer must copy, not hard-link + CountingPathProcessor countingProcessor = new CountingPathProcessor(); + installer = new DefaultInstaller( + countingProcessor, + new StubRepositoryEventDispatcher(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + new StubSyncContextFactory()); + session.setConfigProperty(ConfigurationProperties.INSTALLER_HARD_LINK, false); request.addArtifact(artifact); installer.install(session, request); assertTrue(localArtifactFile.exists()); assertEquals("artifact", TestFileUtils.readString(localArtifactFile)); - // With copy the content is correct; we cannot assert different inodes portably - // but we verify the install succeeds and produces the correct content + // Verify copyWithTimestamp was called (hardLink=false must NOT attempt Files.createLink) + assertEquals( + 1, countingProcessor.copyWithTimestampCount, "copyWithTimestamp must be called when hardLink=false"); + } + + /** PathProcessor wrapper that counts copyWithTimestamp invocations. */ + private static class CountingPathProcessor extends PathProcessorSupport { + int copyWithTimestampCount = 0; + + @Override + public void copyWithTimestamp(Path src, Path dst) throws IOException { + copyWithTimestampCount++; + super.copyWithTimestamp(src, dst); + } } } From 8d475cd6c79e8cb3881c4e4a333324ce38dbe9a0 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 13 Oct 2026 12:44:30 +0000 Subject: [PATCH 5/5] test: assert copyWithTimestamp count on non-POSIX FS in testHardLinkInstallCreatesHardLink On non-POSIX filesystems (Windows, Jimfs), the unix:dev attribute lookup throws UnsupportedOperationException, causing all counting assertions inside the try block to be silently skipped. Add a minimal assertion in the catch block: copyWithTimestampCount must be <= 1 (either the hard link succeeded with count=0, or the fallback fired exactly once with count=1). This ensures the test provides some behavioral coverage even when inode/device checks are unavailable. --- .../aether/internal/impl/DefaultInstallerTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java index 92f6fec706..f840520e24 100644 --- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java +++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java @@ -428,7 +428,13 @@ void testHardLinkInstallCreatesHardLink() throws InstallationException, IOExcept "copyWithTimestamp must be called as fallback on cross-device FS"); } } catch (UnsupportedOperationException | IllegalArgumentException ignored) { - // non-POSIX filesystem — inode/device check not available; content assertion is sufficient + // non-POSIX filesystem — inode/device check not available + // but we can still assert that copyWithTimestamp was called at most once: + // either the hard link succeeded (count=0) or the fallback fired exactly once (count=1) + assertTrue( + countingProcessor.copyWithTimestampCount <= 1, + "copyWithTimestamp must be called at most once on non-POSIX FS (got " + + countingProcessor.copyWithTimestampCount + ")"); } }