diff --git a/.github/workflows/maven-verify.yml b/.github/workflows/maven-verify.yml index 3acefcf1..a970f061 100644 --- a/.github/workflows/maven-verify.yml +++ b/.github/workflows/maven-verify.yml @@ -26,4 +26,5 @@ jobs: name: Verify uses: apache/maven-gh-actions-shared/.github/workflows/maven-verify.yml@v5 with: + jdk-matrix: '[ "17", "21", "25" ]' maven4-enabled: true diff --git a/pom.xml b/pom.xml index f1320489..8c0b82da 100644 --- a/pom.xml +++ b/pom.xml @@ -44,7 +44,8 @@ under the License. - 3.6.3 + + 3.9.6 @@ -71,7 +72,8 @@ under the License. true - 8 + + 17 2.2.0 3.9.16 1.7.36 @@ -190,7 +192,8 @@ under the License. org.codehaus.plexus plexus-io - 3.7.0 + + 3.7.1-SNAPSHOT org.codehaus.plexus @@ -201,7 +204,8 @@ under the License. org.codehaus.plexus plexus-archiver - 4.14.0 + + 5.0.0-SNAPSHOT org.apache.maven @@ -317,7 +321,12 @@ under the License. - from 3.7.0 + from 3.8.1 + 3.9.6 + 17 + + + from 3.7.0 to 3.8.0 3.6.3 8 @@ -415,6 +424,11 @@ under the License. commons-compress 1.28.0 + + org.tukaani + xz + 1.12 + com.github.luben zstd-jni diff --git a/src/it/projects/file-sets/tar-hard-links/assembly.xml b/src/it/projects/file-sets/tar-hard-links/assembly.xml new file mode 100644 index 00000000..ae6e5707 --- /dev/null +++ b/src/it/projects/file-sets/tar-hard-links/assembly.xml @@ -0,0 +1,76 @@ + + + + dist + + tar + tar.gz + tar.bz2 + tar.xz + tar.snappy + tar.zst + + true + distribution + + + + input/plain + plain + + + input/filtered + filtered-fileset + true + + + + + + input/mapped/data.txt + mapped + renamed-data.txt + + + input/mapped/alias.txt + mapped + renamed-alias.txt + + + + input/transformed/data.txt + transformed + original.txt + + + input/transformed/filtered.txt + transformed + filtered.txt + true + + + input/transformed/unix.txt + transformed + unix.txt + unix + + + diff --git a/src/it/projects/file-sets/tar-hard-links/pom.xml b/src/it/projects/file-sets/tar-hard-links/pom.xml new file mode 100644 index 00000000..0a6d1729 --- /dev/null +++ b/src/it/projects/file-sets/tar-hard-links/pom.xml @@ -0,0 +1,87 @@ + + + + 4.0.0 + + org.apache.maven.plugin.assembly.test + it-project-parent + 1 + ../../../it-project-parent/pom.xml + + tar-hard-links + pom + Opt-in TAR hard links survive name mapping but never merge transformed contents. + + filtered value + UTF-8 + + + + + maven-assembly-plugin + + + assembly.xml + + false + + + + default + package + + single + + + default + + + + disabled + package + + single + + + disabled + + false + + + + + enabled + package + + single + + + enabled + + true + + + + + + + + diff --git a/src/it/projects/file-sets/tar-hard-links/setup.groovy b/src/it/projects/file-sets/tar-hard-links/setup.groovy new file mode 100644 index 00000000..ec256946 --- /dev/null +++ b/src/it/projects/file-sets/tar-hard-links/setup.groovy @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.nio.file.Files + +// Git and the Invoker copy cannot store hard-link relationships, so create real aliases after cloning. +['plain': 'plain payload\n', 'mapped': 'mapped payload\n', + 'transformed': 'value=${hardlinks.value}\r\n', 'filtered': 'value=${hardlinks.value}\r\n'].each { name, contents -> + def directory = new File(basedir, "input/$name").toPath() + Files.createDirectories(directory) + def source = directory.resolve('data.txt') + Files.writeString(source, contents) + def aliases = name == 'transformed' ? ['filtered.txt', 'unix.txt'] : ['alias.txt'] + aliases.each { alias -> + def link = directory.resolve(alias) + Files.deleteIfExists(link) + Files.createLink(link, source) + assert Files.isSameFile(source, link) + } +} + +// Matching bytes alone must not make an unrelated file eligible for preservation. +def copy = new File(basedir, 'input/plain/copy.txt').toPath() +Files.deleteIfExists(copy) +Files.copy(new File(basedir, 'input/plain/data.txt').toPath(), copy) +assert !Files.isSameFile(copy, new File(basedir, 'input/plain/data.txt').toPath()) +return true diff --git a/src/it/projects/file-sets/tar-hard-links/verify.groovy b/src/it/projects/file-sets/tar-hard-links/verify.groovy new file mode 100644 index 00000000..1b4946b3 --- /dev/null +++ b/src/it/projects/file-sets/tar-hard-links/verify.groovy @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.nio.file.Files +import java.util.concurrent.TimeUnit + +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.CompressorStreamFactory + +/** + * Opens a TAR payload, decompressing the selected Assembly format when necessary. + * @param archive the generated assembly + * @param compression the Commons Compress name, or null for plain TAR + * @return the payload stream owned by the caller + */ +InputStream openTar(File archive, String compression) { + def input = new BufferedInputStream(new FileInputStream(archive)) + try { + return compression == null ? input : new CompressorStreamFactory().createCompressorInputStream(compression, input) + } catch (Throwable failure) { + input.close() + throw failure + } +} + +/** + * Runs an extractor with bounded execution time and preserves its diagnostics on failure. + * @param command the executable and arguments + * @param log the output file + * @return the command's combined standard output and error + */ +String runCommand(List command, File log) { + def process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log).start() + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly() + throw new AssertionError("Timed out: $command") + } + assert process.exitValue() == 0 : "$command failed: ${log.text}" + return log.text +} + +def formats = ['tar': null, 'tar.gz': 'gz', 'tar.bz2': 'bzip2', 'tar.xz': 'xz', + 'tar.snappy': 'snappy-framed', 'tar.zst': 'zstd'] +def contents = [ + 'plain/data.txt': 'plain payload\n', + 'plain/alias.txt': 'plain payload\n', + 'plain/copy.txt': 'plain payload\n', + 'mapped/renamed-data.txt': 'mapped payload\n', + 'mapped/renamed-alias.txt': 'mapped payload\n', + 'transformed/original.txt': 'value=${hardlinks.value}\r\n', + 'transformed/filtered.txt': 'value=filtered value\r\n', + 'transformed/unix.txt': 'value=${hardlinks.value}\n', + 'filtered-fileset/data.txt': 'value=filtered value\r\n', + 'filtered-fileset/alias.txt': 'value=filtered value\r\n' +].collectEntries { name, value -> ["distribution/$name".toString(), value] } +def groups = [ + ['distribution/plain/data.txt', 'distribution/plain/alias.txt'], + ['distribution/mapped/renamed-data.txt', 'distribution/mapped/renamed-alias.txt'] +] + +// Header/content verification always runs. External utilities are an additional check when installed. +def extractors = ['tar': 'GNU tar', 'bsdtar': 'bsdtar'].findAll { tool, banner -> + try { + def version = runCommand([tool, '--version'], new File(basedir, "target/$tool-version.log")) + if (!version.contains(banner)) { + println "Skipping $banner extraction: $tool is a different implementation" + return false + } + return true + } catch (IOException unavailable) { + println "Skipping $banner extraction: executable is unavailable" + return false + } +}.keySet() + +['default', 'disabled', 'enabled'].each { mode -> + formats.each { format, compression -> + def archive = new File(basedir, "target/$mode-dist.$format") + assert archive.isFile() : "Missing $archive" + def entries = [:] + new TarArchiveInputStream(openTar(archive, compression)).withCloseable { tar -> + def entry + while ((entry = tar.nextTarEntry) != null) { + if (entry.isDirectory()) { + continue + } + assert !entries.containsKey(entry.name) : "Duplicate member ${entry.name} in $archive" + assert contents.containsKey(entry.name) : "Unexpected member ${entry.name} in $archive" + if (entry.isLink()) { + // Links must be empty and point backward to a full payload using its final mapped name. + assert mode == 'enabled' + assert entry.size == 0 + assert entries.containsKey(entry.linkName) : "Forward/missing target ${entry.linkName} in $archive" + assert !entries[entry.linkName].link + assert groups.any { it.contains(entry.name) && it.contains(entry.linkName) } + assert entries[entry.linkName].contents == contents[entry.name] + entries[entry.name] = [link: true, contents: entries[entry.linkName].contents] + } else { + assert entry.isFile() && !entry.isSymbolicLink() + def bytes = tar.readAllBytes() + assert bytes.length == entry.size + assert new String(bytes, 'UTF-8') == contents[entry.name] : "Wrong bytes for ${entry.name} in $archive" + entries[entry.name] = [link: false, contents: new String(bytes, 'UTF-8')] + } + } + } + assert entries.keySet() == contents.keySet() + assert entries.values().count { it.link } == (mode == 'enabled' ? 2 : 0) + groups.each { group -> + assert group.count { !entries[it].link } == (mode == 'enabled' ? 1 : 2) + } + + // Supply decompressed TAR to both tools so even Snappy works without optional CLI decompressors. + // The Commons Compress checks above independently verify every compressed assembly itself. + def payload = new File(basedir, "target/$mode-$format-payload.tar") + openTar(archive, compression).withCloseable { input -> + payload.withOutputStream { output -> input.transferTo(output) } + } + extractors.each { tool -> + def destination = new File(basedir, "target/extracted/$mode-$format-$tool") + assert destination.mkdirs() + runCommand([tool, '-xf', payload.absolutePath, '-C', destination.absolutePath], + new File(basedir, "target/$mode-$format-${tool}.log")) + contents.each { name, expected -> + def file = new File(destination, name).toPath() + assert Files.isRegularFile(file) && !Files.isSymbolicLink(file) + assert Files.readString(file) == expected : "Wrong extracted bytes for $name with $tool" + } + // Compare every pair, checking both preserved aliases and all required independent inodes. + def names = contents.keySet().toList() + names.eachWithIndex { first, index -> + names.drop(index + 1).each { second -> + def linked = mode == 'enabled' && groups.any { it.contains(first) && it.contains(second) } + assert Files.isSameFile(new File(destination, first).toPath(), + new File(destination, second).toPath()) == linked : + "Unexpected inode relationship: $first / $second in $archive with $tool" + } + } + } + assert payload.delete() + println "Verified $archive.name: ${entries.size()} members, ${entries.values().count { it.link }} hard links; extractors=$extractors" + } +} +return true diff --git a/src/main/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiver.java b/src/main/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiver.java index 00bceef5..ed3bd87a 100644 --- a/src/main/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiver.java +++ b/src/main/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiver.java @@ -27,12 +27,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; -import java.util.Date; import java.util.List; -import java.util.Map; import org.apache.maven.plugins.assembly.filter.ContainerDescriptorHandler; -import org.codehaus.plexus.archiver.ArchiveEntry; import org.codehaus.plexus.archiver.ArchiveFinalizer; import org.codehaus.plexus.archiver.ArchivedFileSet; import org.codehaus.plexus.archiver.Archiver; @@ -40,7 +37,6 @@ import org.codehaus.plexus.archiver.FileSet; import org.codehaus.plexus.archiver.FinalizerEnabled; import org.codehaus.plexus.archiver.ResourceIterator; -import org.codehaus.plexus.archiver.util.DefaultArchivedFileSet; import org.codehaus.plexus.archiver.util.DefaultFileSet; import org.codehaus.plexus.components.io.fileselectors.FileInfo; import org.codehaus.plexus.components.io.fileselectors.FileSelector; @@ -56,6 +52,9 @@ *
  • prefixing (where all paths have a set global prefix prepended before addition)
  • *
  • duplication checks on archive additions (for archive-file path + prefix)
  • * + *

    This proxy follows the Plexus Archiver 5 {@link Archiver} API. Custom handlers using removed methods + * must migrate to the resource, file-set and {@link FileTime} APIs and be recompiled, even when hard-link + * preservation is disabled.

    * * @author jdcasey * @@ -75,11 +74,6 @@ public class AssemblyProxyArchiver implements Archiver { private boolean forced; - /** - * @since 2.2 - */ - private boolean useJvmChmod; - public AssemblyProxyArchiver( final String rootPrefix, final Archiver delegate, @@ -125,126 +119,12 @@ public AssemblyProxyArchiver( } } - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addArchivedFileSet( - final File archiveFile, final String prefix, final String[] includes, final String[] excludes) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultArchivedFileSet fs = new DefaultArchivedFileSet(archiveFile); - - fs.setIncludes(includes); - fs.setExcludes(excludes); - fs.setPrefix(rootPrefix + prefix); - fs.setFileSelectors(selectors); - - debug("Adding archived file-set in: " + archiveFile + " to archive location: " + fs.getPrefix()); - - delegate.addArchivedFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - private void debug(final String message) { if (LOGGER.isDebugEnabled()) { LOGGER.debug(message); } } - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addArchivedFileSet(final File archiveFile, final String prefix) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultArchivedFileSet fs = new DefaultArchivedFileSet(archiveFile); - - fs.setPrefix(rootPrefix + prefix); - fs.setFileSelectors(selectors); - - debug("Adding archived file-set in: " + archiveFile + " to archive location: " + fs.getPrefix()); - - delegate.addArchivedFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addArchivedFileSet(final File archiveFile, final String[] includes, final String[] excludes) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultArchivedFileSet fs = new DefaultArchivedFileSet(archiveFile); - - fs.setIncludes(includes); - fs.setExcludes(excludes); - fs.setPrefix(rootPrefix); - fs.setFileSelectors(selectors); - - debug("Adding archived file-set in: " + archiveFile + " to archive location: " + fs.getPrefix()); - - delegate.addArchivedFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addArchivedFileSet(final File archiveFile) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultArchivedFileSet fs = new DefaultArchivedFileSet(archiveFile); - - fs.setPrefix(rootPrefix); - fs.setFileSelectors(selectors); - - debug("Adding archived file-set in: " + archiveFile + " to archive location: " + fs.getPrefix()); - - delegate.addArchivedFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addDirectory( - final File directory, final String prefix, final String[] includes, final String[] excludes) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultFileSet fs = new DefaultFileSet(); - - fs.setDirectory(directory); - fs.setIncludes(includes); - fs.setExcludes(excludes); - fs.setPrefix(rootPrefix + prefix); - fs.setFileSelectors(selectors); - - debug("Adding directory file-set in: " + directory + " to archive location: " + fs.getPrefix()); - - doAddFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - /** * {@inheritDoc} */ @@ -271,74 +151,6 @@ public void addSymlink(String symlinkName, int permissions, String symlinkDestin } } - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addDirectory(final File directory, final String prefix) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultFileSet fs = new DefaultFileSet(); - - fs.setDirectory(directory); - fs.setPrefix(rootPrefix + prefix); - fs.setFileSelectors(selectors); - - debug("Adding directory file-set in: " + directory + " to archive location: " + fs.getPrefix()); - - doAddFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addDirectory(final File directory, final String[] includes, final String[] excludes) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultFileSet fs = new DefaultFileSet(); - - fs.setDirectory(directory); - fs.setIncludes(includes); - fs.setExcludes(excludes); - fs.setPrefix(rootPrefix); - fs.setFileSelectors(selectors); - - debug("Adding directory file-set in: " + directory + " to archive location: " + fs.getPrefix()); - - doAddFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void addDirectory(final File directory) { - inPublicApi.set(Boolean.TRUE); - try { - final DefaultFileSet fs = new DefaultFileSet(); - - fs.setDirectory(directory); - fs.setPrefix(rootPrefix); - fs.setFileSelectors(selectors); - - debug("Adding directory file-set in: " + directory + " to archive location: " + fs.getPrefix()); - - doAddFileSet(fs); - } finally { - inPublicApi.remove(); - } - } - /** * {@inheritDoc} */ @@ -465,17 +277,6 @@ public void setDestFile(final File destFile) { } } - @Override - @SuppressWarnings({"deprecation"}) - public Map getFiles() { - inPublicApi.set(Boolean.TRUE); - try { - return delegate.getFiles(); - } finally { - inPublicApi.remove(); - } - } - /** * {@inheritDoc} */ @@ -781,24 +582,6 @@ public int getOverrideFileMode() { return delegate.getOverrideFileMode(); } - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public boolean isUseJvmChmod() { - return useJvmChmod; - } - - /** - * {@inheritDoc} - */ - @Override - @Deprecated - public void setUseJvmChmod(final boolean useJvmChmod) { - this.useJvmChmod = useJvmChmod; - } - /** * {@inheritDoc} */ @@ -849,29 +632,11 @@ public boolean isSymbolicLink() { } } - @Override - @Deprecated - public void setLastModifiedDate(Date lastModifiedDate) { - delegate.setLastModifiedDate(lastModifiedDate); - } - - @Override - @Deprecated - public Date getLastModifiedDate() { - return delegate.getLastModifiedDate(); - } - @Override public void setFilenameComparator(Comparator filenameComparator) { delegate.setFilenameComparator(filenameComparator); } - @Override - @Deprecated - public void configureReproducible(Date outputTimestamp) { - delegate.configureReproducible(outputTimestamp); - } - @Override public void setOverrideUid(int uid) { delegate.setOverrideUid(uid); diff --git a/src/main/java/org/apache/maven/plugins/assembly/filter/ContainerDescriptorHandler.java b/src/main/java/org/apache/maven/plugins/assembly/filter/ContainerDescriptorHandler.java index 087b3c64..ca11a131 100644 --- a/src/main/java/org/apache/maven/plugins/assembly/filter/ContainerDescriptorHandler.java +++ b/src/main/java/org/apache/maven/plugins/assembly/filter/ContainerDescriptorHandler.java @@ -22,6 +22,11 @@ import org.codehaus.plexus.components.io.fileselectors.FileSelector; /** - * + * Customizes archive contents through selection and finalization callbacks. + *

    Handlers that call APIs removed by Plexus Archiver 5 must migrate and be recompiled against the + * version used by this plugin. In particular, use {@code Archiver.getResources()} instead of + * {@code Archiver.getFiles()}, and the {@code FileTime} timestamp methods instead of the removed + * {@code Date} methods. Assembly's proxy follows the Archiver 5 API and does not provide compatibility + * shims for removed methods.

    */ public interface ContainerDescriptorHandler extends ArchiveFinalizer, FileSelector {} diff --git a/src/main/java/org/apache/maven/plugins/assembly/mojos/AbstractAssemblyMojo.java b/src/main/java/org/apache/maven/plugins/assembly/mojos/AbstractAssemblyMojo.java index 2b58dca6..632fb0c4 100644 --- a/src/main/java/org/apache/maven/plugins/assembly/mojos/AbstractAssemblyMojo.java +++ b/src/main/java/org/apache/maven/plugins/assembly/mojos/AbstractAssemblyMojo.java @@ -310,7 +310,11 @@ public abstract class AbstractAssemblyMojo extends AbstractMojo implements Assem *
          * <appxml>${project.basedir}/somepath/app.xml</appxml>
          * 
    - *

    + *

    For TAR and compressed TAR formats, hard-link preservation can be enabled with + * {@code true}. It is disabled by default. + * Eligible, untransformed files with the same hard-link identity and compatible output metadata share one + * payload; subsequent entries refer to the earlier entry's final archive name. Filtering and line-ending + * transformations keep contents independent. Configure this option only on executions producing TAR formats.

    * * @since 2.2-beta-3 */ diff --git a/src/site/markdown/examples/single/index.md b/src/site/markdown/examples/single/index.md index 6c141dc8..9467db1d 100644 --- a/src/site/markdown/examples/single/index.md +++ b/src/site/markdown/examples/single/index.md @@ -31,6 +31,7 @@ A Single Project is a project whose assemblies do not use <moduleSets>. The examples below may help you in creating assemblies for your single projects. - [Filtering Some Distribution Files](./filtering-some-distribution-files.html) +- [Preserving TAR Hard Links](./preserving-tar-hard-links.html) - [Including/Excluding Artifacts](./including-and-excluding-artifacts.html) - [Using Component Descriptors](./using-components.html) - [Using Container Descriptor Handlers](./using-container-descriptor-handlers.html) diff --git a/src/site/markdown/examples/single/preserving-tar-hard-links.md b/src/site/markdown/examples/single/preserving-tar-hard-links.md new file mode 100644 index 00000000..f2444832 --- /dev/null +++ b/src/site/markdown/examples/single/preserving-tar-hard-links.md @@ -0,0 +1,124 @@ + + +# Preserving TAR Hard Links + +Hard-link preservation is **disabled by default**. Without configuration, each +selected regular file is stored with its own contents, even when source names +refer to the same inode. + +To enable preservation, use the existing `archiverConfig` parameter in an +execution that produces only TAR formats: + +```xml + + org.apache.maven.plugins + maven-assembly-plugin + 3.8.1-SNAPSHOT + + + src/assembly/distribution.xml + + + true + + + + + distribution + package + + single + + + + +``` + +For example, `src/assembly/distribution.xml` can contain: + +```xml + + distribution + + tar + tar.gz + + + + src/distribution + payload + + + +``` + +The option applies to plain TAR and all supported compressed TAR formats: +`tar.gz`/`tgz`, `tar.bz2`/`tbz2`, `tar.xz`/`txz`, `tar.snappy`, and `tar.zst`. +Set `preserveHardLinks` to `false`, or omit it, to store each file independently. +For an assembly that also produces ZIP, JAR, or other formats, use a separate +execution for TAR: the reflective option belongs to the TAR archiver. + +## What is preserved + +When selected source names are hard links to the same regular file and have +compatible output metadata, the first eligible entry stores the contents. +Subsequent aliases contain zero-length hard-link headers referring to that earlier +entry. GNU tar and bsdtar extract these entries as files sharing an inode. +Targets use the final archive names, including the assembly base directory, +`outputDirectory`, and `destName` mappings. + +Preservation requires a known identity for untransformed resource contents. +Unavailable filesystem file keys, custom content suppliers, filtering, line-ending +conversion, and incompatible output metadata cause entries to be stored with +independent payloads. Filtering remains independent even if the resulting bytes +happen to match. Unrelated files with identical bytes are not combined. Symbolic +links retain their usual representation. `tarLongFileMode=truncate` disables +hard-link preservation because truncated target names may be ambiguous. + +Hard-link headers refer to paths. As with GNU tar and bsdtar, a later write through +a directory symlink can replace the contents at a target path before an alias is +extracted. Avoid overlapping output paths when the original contents must be +retained for every alias. See the +[Plexus Archiver hard-link documentation](https://codehaus-plexus.github.io/plexus-archiver/hard-links.html) +for writer and extraction semantics. + +## Development dependencies and Java requirement + +This development version uses `plexus-archiver:5.0.0-SNAPSHOT` and +`plexus-io:3.7.1-SNAPSHOT`, containing +[Plexus Archiver PR #493](https://github.com/codehaus-plexus/plexus-archiver/pull/493) +and [Plexus IO PR #191](https://github.com/codehaus-plexus/plexus-io/pull/191). +Until releases containing both changes are available, install the companion IO +branch first with `mvn install`, then the Archiver branch, before building Assembly. +Both dependency versions must be replaced with released versions before an +Assembly release; an ordinary build cannot fetch these locally built PR snapshots +from Maven Central. + +Plexus Archiver 5 requires Java 17. Consequently, this Assembly development version +requires Maven to run on Java 17 or newer, including when preservation is disabled. +Maven 3.9.6 or newer is also required: older Maven versions bundle a Sisu injector +that cannot discover these Java 17 components. Adopting this dependency therefore +raises the requirements from Assembly 3.8.0's Java 8 and Maven 3.6.3 baseline. + +The Archiver 5 upgrade also removes deprecated methods from the `Archiver` +interface. Existing custom handlers that call those methods must be migrated and +recompiled. See +[Migrating custom handlers to Plexus Archiver 5](./using-container-descriptor-handlers.html#migrating-custom-handlers-to-plexus-archiver-5) +for the supported replacement APIs. diff --git a/src/site/markdown/examples/single/using-container-descriptor-handlers.md.vm b/src/site/markdown/examples/single/using-container-descriptor-handlers.md.vm index 62d8612d..39addf0c 100644 --- a/src/site/markdown/examples/single/using-container-descriptor-handlers.md.vm +++ b/src/site/markdown/examples/single/using-container-descriptor-handlers.md.vm @@ -89,6 +89,38 @@ The plugin comes with several handlers already defined. </assembly> +## Migrating custom handlers to Plexus Archiver 5 + +Assembly 3.8.1-SNAPSHOT uses Plexus Archiver 5, which removes deprecated methods +from the `Archiver` interface. Existing custom handlers that call these methods +**must update their source and be recompiled** against this Assembly version. + +A handler compiled against Archiver 4 can otherwise fail with `NoSuchMethodError` +when invoking `Archiver.getFiles()` or `Archiver.setLastModifiedDate(Date)`. +`AssemblyProxyArchiver` follows the Archiver 5 API and provides no compatibility +shims for these removed methods. Handlers should use the current `Archiver` +interface rather than cast to Assembly's concrete proxy. + +| Removed interface API | Replacement | +| --- | --- | +| `getFiles()` | Iterate `getResources()`; construct a map explicitly if needed. | +| `setLastModifiedDate(Date)` | `setLastModifiedTime(FileTime)`; use `FileTime.fromMillis(date.getTime())`, or `null` to retain source timestamps. | +| `getLastModifiedDate()` | `getLastModifiedTime()`; handle `null` before converting with `new Date(time.toMillis())`. | +| `configureReproducible(Date)` | `configureReproducibleBuild(FileTime)` with a non-null timestamp. | +| `addDirectory(File, ...)` | Configure a `DefaultFileSet` and call `addFileSet(FileSet)`. | +| `addArchivedFileSet(File, ...)` | Configure a `DefaultArchivedFileSet` and call `addArchivedFileSet(ArchivedFileSet)`. | +| `isUseJvmChmod()` / `setUseJvmChmod(boolean)` | Remove these calls; permission changes use the JVM. | + +Resource iteration preserves archive order and can return directories, symbolic +links, and repeated names. Unlike the old map, it does not collapse duplicate +names. A replacement map should honor `getIncludeEmptyDirs()`, include regular +files when that setting is false, and retain the last entry for each repeated +name to reproduce the old map view. + +After migrating, test the handler through its `finalizeArchiveCreation(Archiver)` +callback with the plugin's dependencies. Tests of concrete proxy calls alone do +not verify the interface calls made by a custom handler. + Custom container descriptor handlers ------------------------------------ diff --git a/src/site/site.xml b/src/site/site.xml index 25fbac34..2dd3286f 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -52,6 +52,7 @@ under the License. + diff --git a/src/test/java/org/apache/maven/plugins/assembly/archive/DefaultAssemblyArchiverTest.java b/src/test/java/org/apache/maven/plugins/assembly/archive/DefaultAssemblyArchiverTest.java index bf3a0e3c..b3795d5f 100644 --- a/src/test/java/org/apache/maven/plugins/assembly/archive/DefaultAssemblyArchiverTest.java +++ b/src/test/java/org/apache/maven/plugins/assembly/archive/DefaultAssemblyArchiverTest.java @@ -205,6 +205,31 @@ void createArchiverShouldConfigureArchiver() throws Exception { verify(archiverManager).getArchiver("dummy"); } + /** Verifies that the existing reflective configuration can enable and explicitly disable TAR hard links. */ + @Test + void createArchiverShouldConfigureHardLinkPreservation() throws Exception { + for (boolean preserve : new boolean[] {true, false}) { + final TarArchiver tarArchiver = new TarArchiver(); + // Start from the opposite value so both configuration values must reach the real setter. + tarArchiver.setPreserveHardLinks(!preserve); + when(archiverManager.getArchiver("tar")).thenReturn(tarArchiver); + + final AssemblerConfigurationSource configSource = mock(AssemblerConfigurationSource.class); + when(configSource.getProject()).thenReturn(new MavenProject(new Model())); + when(configSource.getTarLongFileMode()).thenReturn(TarLongFileMode.fail.toString()); + when(configSource.getWorkingDirectory()).thenReturn(temporaryFolder); + when(configSource.getArchiverConfig()) + .thenReturn( + "" + preserve + ""); + setupInterpolators(configSource); + + createSubject(Collections.emptyList()) + .createArchiver("tar", false, "finalName", configSource, Collections.emptyList(), null); + + assertEquals(preserve, tarArchiver.isPreserveHardLinks()); + } + } + @Test void createArchiverShouldCreateTarArchiverWithNoCompression() throws Exception { final TestTarArchiver ttArchiver = new TestTarArchiver(); @@ -225,6 +250,7 @@ void createArchiverShouldCreateTarArchiverWithNoCompression() throws Exception { subject.createArchiver("tar", false, "finalName", configSource, null, null); assertNull(ttArchiver.compressionMethod); + assertFalse(ttArchiver.isPreserveHardLinks()); assertEquals(TarLongFileMode.fail, ttArchiver.longFileMode); // result of easymock migration, should be assert of expected result instead of verifying methodcalls diff --git a/src/test/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiverTest.java b/src/test/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiverTest.java index 032e914e..6b7c2f3a 100644 --- a/src/test/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiverTest.java +++ b/src/test/java/org/apache/maven/plugins/assembly/archive/archiver/AssemblyProxyArchiverTest.java @@ -133,8 +133,7 @@ void addFileNoPermsCallAcceptFilesOnlyOnce() throws Exception { } @Test - @SuppressWarnings("deprecation") - void addDirectoryNoPermsCallAcceptFilesOnlyOnce() throws Exception { + void addFileSetNoPermsCallAcceptFilesOnlyOnce() throws Exception { final Archiver delegate = new JarArchiver(); final File output = File.createTempFile("junit", null, temporaryFolder); @@ -154,7 +153,7 @@ void addDirectoryNoPermsCallAcceptFilesOnlyOnce() throws Exception { Files.write( dir.toPath().resolve("file.txt"), Collections.singletonList("This is a test."), StandardCharsets.UTF_8); - archiver.addDirectory(dir); + archiver.addFileSet(DefaultFileSet.fileSet(dir)); archiver.createArchive();