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 b8d676c95..91aaff4aa 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.25 + */ + public static final String PREFIX_INSTALLER = PREFIX_AETHER + "installer."; + /** * Prefix for transport related configurations. For internal use only. * @@ -689,6 +696,37 @@ 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, 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 + * 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.25 + * @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.25 + */ + 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 5533c480a..0a02e8289 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; @@ -147,6 +151,12 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s result.addMetadata(metadata); } + boolean hardLink = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK, + ConfigurationProperties.INSTALLER_HARD_LINK); + + Exception linkFailure = null; for (ListIterator iterator = artifacts.listIterator(); iterator.hasNext(); ) { Artifact artifact = iterator.next(); @@ -156,11 +166,22 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s iterator.set(artifact); - install(session, trace, artifact); + 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); @@ -191,7 +212,12 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s } } - private void install(RepositorySystemSession session, RequestTrace trace, Artifact artifact) + /** + * 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(); @@ -199,13 +225,29 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa artifactInstalling(session, trace, artifact, dstPath); + Exception linkFailure = null; Exception exception = null; try { if (dstPath.equals(srcPath)) { throw new IllegalStateException("cannot install " + dstPath + " to same path"); } - pathProcessor.copyWithTimestamp(srcPath, dstPath); + if (hardLink) { + Files.createDirectories(dstPath.getParent()); + if (Files.isDirectory(dstPath)) { + throw new IOException("destination is a directory: " + dstPath); + } + try { + 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 + linkFailure = e; + pathProcessor.copyWithTimestamp(srcPath, dstPath); + } + } else { + pathProcessor.copyWithTimestamp(srcPath, dstPath); + } lrm.add(session, new LocalArtifactRegistration(artifact)); } catch (Exception e) { exception = e; @@ -213,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 e6fa5e84f..f840520e2 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,96 @@ 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 + // + // 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, 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(); + 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, 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"); + } + } catch (UnsupportedOperationException | IllegalArgumentException ignored) { + // 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 + ")"); + } + } + + @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)); + // 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); + } + } }