diff --git a/src/main/java/org/codehaus/plexus/components/io/functions/HardLinkIdentitySupplier.java b/src/main/java/org/codehaus/plexus/components/io/functions/HardLinkIdentitySupplier.java new file mode 100644 index 0000000..cdf97eb --- /dev/null +++ b/src/main/java/org/codehaus/plexus/components/io/functions/HardLinkIdentitySupplier.java @@ -0,0 +1,39 @@ +package org.codehaus.plexus.components.io.functions; + +/* + * Copyright 2026 The plexus developers. + * + * Licensed 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 + * + * http://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. + */ + +import java.io.IOException; + +/** + * Optional identity for resources whose contents can share one hard-link payload. + * Implementations must return null when transformation or a replacement content + * supplier invalidates that relationship. Equal identities must denote the same + * content within a stable source, not merely equal bytes from unrelated files. + * Consumers must also compare the metadata they intend to write. + * + * @since 3.7.1 + */ +public interface HardLinkIdentitySupplier { + /** + * Returns a source-scoped identity suitable for equality comparisons. + * Identities must only be cached for the duration of one archive operation. + * + * @return the content identity, or null when preservation cannot be guaranteed + * @throws IOException if source attributes cannot be read + */ + Object getHardLinkIdentity() throws IOException; +} diff --git a/src/main/java/org/codehaus/plexus/components/io/resources/Deferred.java b/src/main/java/org/codehaus/plexus/components/io/resources/Deferred.java index ffb040d..becef37 100644 --- a/src/main/java/org/codehaus/plexus/components/io/resources/Deferred.java +++ b/src/main/java/org/codehaus/plexus/components/io/resources/Deferred.java @@ -25,11 +25,12 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.DeferredFileOutputStream; import org.codehaus.plexus.components.io.functions.ContentSupplier; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; import org.codehaus.plexus.components.io.functions.NameSupplier; import org.codehaus.plexus.components.io.functions.SizeSupplier; import org.codehaus.plexus.components.io.resources.proxy.ProxyFactory; -class Deferred implements ContentSupplier, NameSupplier, SizeSupplier { +class Deferred implements ContentSupplier, NameSupplier, SizeSupplier, HardLinkIdentitySupplier { final DeferredFileOutputStream dfos; final PlexusIoResource resource; @@ -87,6 +88,19 @@ public String getName() { return owner.getName(resource); } + /** + * Preserves source identity only when this wrapper has not transformed the contents. + * + * @return the source identity, or null after transformation + * @throws IOException if the source identity cannot be read + */ + @Override + public Object getHardLinkIdentity() throws IOException { + return dfos == null && resource instanceof HardLinkIdentitySupplier + ? ((HardLinkIdentitySupplier) resource).getHardLinkIdentity() + : null; + } + public PlexusIoResource asResource() { return ProxyFactory.createProxy(resource, Deferred.this); } diff --git a/src/main/java/org/codehaus/plexus/components/io/resources/PlexusIoFileResource.java b/src/main/java/org/codehaus/plexus/components/io/resources/PlexusIoFileResource.java index dbd0a53..8b600c7 100755 --- a/src/main/java/org/codehaus/plexus/components/io/resources/PlexusIoFileResource.java +++ b/src/main/java/org/codehaus/plexus/components/io/resources/PlexusIoFileResource.java @@ -25,7 +25,10 @@ import java.io.InputStream; import java.net.URL; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; +import java.util.Arrays; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.DeferredFileOutputStream; @@ -34,6 +37,7 @@ import org.codehaus.plexus.components.io.attributes.PlexusIoResourceAttributes; import org.codehaus.plexus.components.io.functions.ContentSupplier; import org.codehaus.plexus.components.io.functions.FileSupplier; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; import org.codehaus.plexus.components.io.functions.InputStreamTransformer; import org.codehaus.plexus.components.io.functions.ResourceAttributeSupplier; @@ -42,7 +46,8 @@ /** * Implementation of {@link PlexusIoResource} for files. */ -public class PlexusIoFileResource extends AbstractPlexusIoResource implements ResourceAttributeSupplier, FileSupplier { +public class PlexusIoFileResource extends AbstractPlexusIoResource + implements ResourceAttributeSupplier, FileSupplier, HardLinkIdentitySupplier { @Nonnull private final File file; @@ -55,6 +60,8 @@ public class PlexusIoFileResource extends AbstractPlexusIoResource implements Re private final ContentSupplier contentSupplier; + private final boolean originalContent; + private final DeferredFileOutputStream dfos; protected PlexusIoFileResource(@Nonnull File file, @Nonnull String name, @Nonnull PlexusIoResourceAttributes attrs) @@ -98,6 +105,8 @@ protected PlexusIoFileResource(@Nonnull File file, @Nonnull String name, @Nonnul boolean hasTransformer = streamTransformer != null && streamTransformer != identityTransformer; InputStreamTransformer transToUse = streamTransformer != null ? streamTransformer : identityTransformer; + originalContent = contentSupplier == null && !hasTransformer; + dfos = hasTransformer && file.isFile() ? asDeferredStream(this.contentSupplier, transToUse, this) : null; } @@ -205,6 +214,28 @@ public boolean isSymbolicLink() { return getAttributes().isSymbolicLink(); } + /** + * Identifies an untransformed regular file without following symbolic links. + * Subclasses that change content must explicitly supply their own guarantee. + * + * @return a filesystem-scoped file identity, or null for unknown content + * @throws IOException if the file attributes cannot be read + */ + @Override + public Object getHardLinkIdentity() throws IOException { + // A backing file alone does not describe custom suppliers or transformed bytes. + if (!originalContent || getClass() != PlexusIoFileResource.class) { + return null; + } + BasicFileAttributes attrs = + Files.readAttributes(file.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attrs.isRegularFile() || attrs.fileKey() == null) { + return null; + } + // Include the observed content version so a changed source cannot reuse an old payload. + return Arrays.asList(file.toPath().getFileSystem(), attrs.fileKey(), attrs.size(), attrs.lastModifiedTime()); + } + protected DeferredFileOutputStream getDfos() { return dfos; } diff --git a/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ProxyFactory.java b/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ProxyFactory.java index 3670a2e..bc9f6e0 100644 --- a/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ProxyFactory.java +++ b/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ProxyFactory.java @@ -22,6 +22,7 @@ import java.util.List; import org.codehaus.plexus.components.io.functions.FileSupplier; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; import org.codehaus.plexus.components.io.functions.ResourceAttributeSupplier; import org.codehaus.plexus.components.io.functions.SymlinkDestinationSupplier; import org.codehaus.plexus.components.io.resources.PlexusIoResource; @@ -31,6 +32,9 @@ public static PlexusIoResource createProxy(@Nonnull PlexusIoResource target, Obj List interfaces = new ArrayList<>(); interfaces.add(PlexusIoResource.class); + if (target instanceof HardLinkIdentitySupplier || alternateSupplier instanceof HardLinkIdentitySupplier) { + interfaces.add(HardLinkIdentitySupplier.class); + } if (target instanceof SymlinkDestinationSupplier) interfaces.add(SymlinkDestinationSupplier.class); if (target instanceof FileSupplier) interfaces.add(FileSupplier.class); if (target instanceof ResourceAttributeSupplier) interfaces.add(ResourceAttributeSupplier.class); diff --git a/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ResourceInvocationHandler.java b/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ResourceInvocationHandler.java index 7ac62ce..2c7d95a 100644 --- a/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ResourceInvocationHandler.java +++ b/src/main/java/org/codehaus/plexus/components/io/resources/proxy/ResourceInvocationHandler.java @@ -21,6 +21,7 @@ import java.lang.reflect.Method; import org.codehaus.plexus.components.io.functions.ContentSupplier; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; import org.codehaus.plexus.components.io.functions.NameSupplier; import org.codehaus.plexus.components.io.functions.ResourceAttributeSupplier; import org.codehaus.plexus.components.io.functions.SizeSupplier; @@ -30,6 +31,8 @@ class ResourceInvocationHandler implements InvocationHandler { private final PlexusIoResource testImpl; + private final HardLinkIdentitySupplier hardLinkIdentitySupplier; + private final ContentSupplier contentSupplier; private final NameSupplier nameSupplier; private final SizeSupplier sizeSupplier; @@ -38,6 +41,7 @@ class ResourceInvocationHandler implements InvocationHandler { public ResourceInvocationHandler(@Nonnull PlexusIoResource target, Object alternativeHandler) { this.testImpl = target; + this.hardLinkIdentitySupplier = asOrNull(alternativeHandler, HardLinkIdentitySupplier.class); this.contentSupplier = asOrNull(alternativeHandler, ContentSupplier.class); this.nameSupplier = asOrNull(alternativeHandler, NameSupplier.class); this.sizeSupplier = asOrNull(alternativeHandler, SizeSupplier.class); @@ -53,6 +57,15 @@ private static T asOrNull(Object instance, Class clazz) { public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String name = method.getName(); + if ("getHardLinkIdentity".equals(name)) { + // Replacing contents invalidates identity unless the wrapper explicitly preserves it. + if (hardLinkIdentitySupplier != null) { + return hardLinkIdentitySupplier.getHardLinkIdentity(); + } + return contentSupplier == null && sizeSupplier == null && testImpl instanceof HardLinkIdentitySupplier + ? ((HardLinkIdentitySupplier) testImpl).getHardLinkIdentity() + : null; + } if (contentSupplier != null && "getContents".equals(name)) { return contentSupplier.getContents(); } diff --git a/src/test/java/org/codehaus/plexus/components/io/resources/HardLinkIdentityTest.java b/src/test/java/org/codehaus/plexus/components/io/resources/HardLinkIdentityTest.java new file mode 100644 index 0000000..45cc0bd --- /dev/null +++ b/src/test/java/org/codehaus/plexus/components/io/resources/HardLinkIdentityTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 The plexus developers. + * + * Licensed 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 + * + * http://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. + */ +package org.codehaus.plexus.components.io.resources; + +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.codehaus.plexus.components.io.functions.ContentSupplier; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; +import org.codehaus.plexus.components.io.functions.NameSupplier; +import org.codehaus.plexus.components.io.resources.proxy.ProxyFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class HardLinkIdentityTest { + @TempDir + Path temp; + + /** Checks filesystem identity and invalidation when effective content changes. */ + @Test + void identifiesOnlyOriginalContent() throws Exception { + Path first = Files.write(temp.resolve("first"), new byte[] {1}); + Path second = temp.resolve("second"); + try { + Files.createLink(second, first); + } catch (java.io.IOException | UnsupportedOperationException e) { + assumeTrue(false, "Hard links unavailable: " + e); + } + PlexusIoResource a = ResourceFactory.createResource(first.toFile()); + PlexusIoResource b = ResourceFactory.createResource(second.toFile()); + Object identity = ((HardLinkIdentitySupplier) a).getHardLinkIdentity(); + assumeTrue(identity != null, "Filesystem does not expose file keys"); + assertEquals(identity, ((HardLinkIdentitySupplier) b).getHardLinkIdentity()); + PlexusIoResource transformed = ResourceFactory.createResource( + first.toFile(), (resource, input) -> new ByteArrayInputStream(new byte[] {2})); + assertNull(((HardLinkIdentitySupplier) transformed).getHardLinkIdentity()); + PlexusIoResource supplied = ResourceFactory.createResource( + first.toFile(), + "custom", + () -> new ByteArrayInputStream(new byte[] {3}), + (org.codehaus.plexus.components.io.functions.InputStreamTransformer) null); + assertNull(((HardLinkIdentitySupplier) supplied).getHardLinkIdentity()); + } + + /** Transparent renaming keeps identity while a replacement stream invalidates it. */ + @Test + void proxiesRespectContentOverrides() throws Exception { + Path file = Files.write(temp.resolve("file"), new byte[] {1}); + PlexusIoResource original = ResourceFactory.createResource(file.toFile()); + Object identity = ((HardLinkIdentitySupplier) original).getHardLinkIdentity(); + PlexusIoResource renamed = ProxyFactory.createProxy(original, (NameSupplier) () -> "renamed"); + assertEquals(identity, ((HardLinkIdentitySupplier) renamed).getHardLinkIdentity()); + PlexusIoResource replaced = + ProxyFactory.createProxy(original, (ContentSupplier) () -> new ByteArrayInputStream(new byte[] {2})); + assertNull(((HardLinkIdentitySupplier) replaced).getHardLinkIdentity()); + } + + /** Collection resolution must explicitly preserve or discard the source guarantee. */ + @Test + void deferredResourcesRespectTransformers() throws Exception { + Path file = Files.write(temp.resolve("file"), new byte[] {1}); + PlexusIoResource original = ResourceFactory.createResource(file.toFile()); + Object identity = ((HardLinkIdentitySupplier) original).getHardLinkIdentity(); + PlexusIoResourceCollection owner = new PlexusIoFileResourceCollection(); + assertEquals( + identity, + ((HardLinkIdentitySupplier) new Deferred(original, owner, false).asResource()).getHardLinkIdentity()); + assertNull(((HardLinkIdentitySupplier) new Deferred(original, owner, true).asResource()).getHardLinkIdentity()); + } +}