Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ public final class ConfigurationProperties {
*/
public static final String PREFIX_UTIL = PREFIX_AETHER + "util.";

/**
* Prefix for installer related configurations. <em>For internal use only.</em>
*
* @since 2.0.25
*/
public static final String PREFIX_INSTALLER = PREFIX_AETHER + "installer.";

/**
* Prefix for transport related configurations. <em>For internal use only.</em>
*
Expand Down Expand Up @@ -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.
* <p>
* Hard links require that the source and target paths reside on the <strong>same filesystem volume</strong>.
* 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.
* <p>
* <strong>Note:</strong> hard links share an inode. Any tool that overwrites the build output file
* <em>in-place</em> (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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<Artifact> iterator = artifacts.listIterator(); iterator.hasNext(); ) {
Artifact artifact = iterator.next();

Expand All @@ -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);

Expand Down Expand Up @@ -191,28 +212,50 @@ 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();
final Path dstPath = lrm.getAbsolutePathForLocalArtifact(artifact);

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);
}
Comment thread
gnodet marked this conversation as resolved.
Comment thread
gnodet marked this conversation as resolved.
Comment thread
gnodet marked this conversation as resolved.
} else {
Comment thread
gnodet marked this conversation as resolved.
pathProcessor.copyWithTimestamp(srcPath, dstPath);
}
lrm.add(session, new LocalArtifactRegistration(artifact));
} catch (Exception e) {
exception = e;
throw new InstallationException("Failed to install artifact " + artifact + ": " + e.getMessage(), e);
} finally {
artifactInstalled(session, trace, artifact, dstPath, exception);
}
return linkFailure;
}

private void install(RepositorySystemSession session, RequestTrace trace, Metadata metadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Comment thread
gnodet marked this conversation as resolved.
Comment thread
gnodet marked this conversation as resolved.
Comment thread
gnodet marked this conversation as resolved.
Comment thread
gnodet marked this conversation as resolved.
// 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());
Comment thread
gnodet marked this conversation as resolved.
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);
}
}
Comment thread
gnodet marked this conversation as resolved.
}
Comment thread
gnodet marked this conversation as resolved.
Loading