components = new ArrayList<>();
+ for (String component : name.split("/")) {
+ if (!component.isEmpty() && !component.equals(".")) {
+ components.add(component);
+ }
+ }
+ return String.join("/", components);
+ }
+}
diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarFile.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarFile.java
index e3d9e6fe..54cc4c76 100644
--- a/src/main/java/org/codehaus/plexus/archiver/tar/TarFile.java
+++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarFile.java
@@ -1,5 +1,6 @@
package org.codehaus.plexus.archiver.tar;
+import java.io.Closeable;
import java.io.File;
import java.io.FilterInputStream;
import java.io.IOException;
@@ -8,6 +9,7 @@
import java.util.Enumeration;
import java.util.NoSuchElementException;
+import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.codehaus.plexus.archiver.ArchiveFile;
@@ -16,152 +18,226 @@
import static org.codehaus.plexus.archiver.util.Streams.bufferedInputStream;
/**
- *
- * Implementation of {@link ArchiveFile} for tar files.
- *
- * Compared to
- * {@link org.apache.commons.compress.archivers.zip.ZipFile}, this one should be used with some care, due to the
- * nature of a tar file: While a zip file contains a catalog, a tar
- * file does not. In other words, the only way to read a tar file in
- * a performant manner is by iterating over it from the beginning to
- * the end. If you try to open another entry than the "next" entry,
- * then you force to skip entries, until the requested entry is found.
- * This may require to reread the entire file!
- *
- * In other words, the recommended use of this class is to use
- * {@link #getEntries()} and invoke {@link #getInputStream(TarArchiveEntry)}
- * only for the current entry. Basically, this is to handle it like
- * {@link TarArchiveInputStream}.
- *
- * The advantage of this class is that you may write code for the
- * {@link ArchiveFile}, which is valid for both tar files and zip files.
+ * Implementation of {@link ArchiveFile} for TAR files, allowing consumers to use
+ * the same archive abstraction for TAR and ZIP files.
+ *
+ * Unlike {@link org.apache.commons.compress.archivers.zip.ZipFile}, TAR has no
+ * catalog for direct entry lookup. Headers are recorded as they are encountered;
+ * opening an enumeration does not scan the archive. For efficient access, iterate
+ * over {@link #getEntries()} and consume each current member's contents in archive
+ * order, as with {@link TarArchiveInputStream}. This reads the source once.
+ *
+ *
Explicitly requesting an earlier or already-consumed member's contents may
+ * reopen the archive and scan to that member, including decompression for compressed
+ * sources. These on-demand reads use a separate cursor and do not skip enumeration
+ * entries. Finish reading and close each content stream before requesting another;
+ * concurrent content streams and simultaneous enumerations are not supported.
+ *
+ *
Hard links must refer to earlier archive members. Their logical streams expose
+ * the data-bearing member's bytes, which are cached on demand once per occurrence.
+ * Merely enumerating a hard link or inspecting its metadata does not read its payload.
+ * Close this reader and its content streams, preferably using try-with-resources,
+ * to release handles and delete cached payloads. Closing a content stream leaves
+ * the reader open.
*/
-public class TarFile implements ArchiveFile {
-
- private final java.io.File file;
-
- private TarArchiveInputStream inputStream;
-
- private TarArchiveEntry currentEntry;
-
- /**
- * Creates a new instance with the given file.
- */
+public class TarFile implements ArchiveFile, Closeable {
+ private final File file;
+ private Cursor enumerationCursor;
+ private Cursor replayCursor;
+ private TarEntryIndex index = new TarEntryIndex();
+ private TarPayloads linkPayloads;
+
+ /** Creates an archive reader; streams are opened lazily. */
public TarFile(File file) {
this.file = file;
}
/**
- * Implementation of {@link ArchiveFile#getEntries()}. Note, that there is
- * an interaction between this method and {@link #getInputStream(TarArchiveEntry)},
- * or {@link #getInputStream(org.apache.commons.compress.archivers.ArchiveEntry)}:
- * If an input stream is opened for any other entry than the enumerations
- * current entry, then entries may be skipped.
+ * Lazily enumerates headers in archive order, without a preliminary scan.
+ * Pass returned headers to {@link #getInputStream(TarArchiveEntry)} to identify
+ * exact occurrences when names repeat. Content lookups do not advance enumeration.
+ *
+ * @return the archive headers in their original order
+ * @throws IOException if the source cannot be opened
*/
@Override
- public Enumeration getEntries() throws IOException {
- if (inputStream != null) {
- close();
+ public Enumeration getEntries() throws IOException {
+ if (enumerationCursor != null) {
+ enumerationCursor.close();
}
- open();
- return new Enumeration() {
-
- boolean currentEntryValid;
+ Cursor cursor = enumerationCursor = new Cursor();
+ return new Enumeration() {
+ private TarArchiveEntry next;
+ private boolean ready;
+ private boolean ended;
+ /** Reads at most one new header, so repeated checks cannot skip a member. */
@Override
public boolean hasMoreElements() {
- if (!currentEntryValid) {
+ if (!ready && !ended) {
try {
- currentEntry = inputStream.getNextEntry();
+ next = cursor.next();
+ ready = next != null;
+ ended = !ready;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
- return currentEntry != null;
+ return ready;
}
+ /** Returns the pending header while leaving its payload ready for a sequential read. */
@Override
- public org.apache.commons.compress.archivers.ArchiveEntry nextElement() {
- if (currentEntry == null) {
+ public ArchiveEntry nextElement() {
+ if (!hasMoreElements()) {
throw new NoSuchElementException();
}
- currentEntryValid = false;
- return currentEntry;
+ ready = false;
+ return next;
}
};
}
- public void close() throws IOException {
- if (inputStream != null) {
- inputStream.close();
- inputStream = null;
+ /** Returns metadata already encountered by enumeration or an explicit lookup. */
+ TarEntryIndex index() {
+ return index;
+ }
+
+ /** Finds a caller-created header on demand without moving the enumeration cursor. */
+ private TarArchiveEntry findEntry(TarArchiveEntry entry) throws IOException {
+ Integer position = index.find(entry);
+ if (position != null) {
+ return index.entry(position);
+ }
+ if (replayCursor == null) {
+ replayCursor = new Cursor();
}
+ TarArchiveEntry next;
+ while ((next = replayCursor.next()) != null) {
+ if (TarEntryIndex.normalizedName(next.getName()).equals(TarEntryIndex.normalizedName(entry.getName()))) {
+ return next;
+ }
+ }
+ throw new IOException("Unknown TAR entry: " + entry.getName());
+ }
+
+ /** Resolves backward links using recorded headers, with no payload reads. */
+ TarArchiveEntry resolve(TarArchiveEntry entry) throws IOException {
+ return index.resolve(findEntry(entry));
}
+ /** Returns logical contents while preserving occurrence identity in TAR headers. */
@Override
- public InputStream getInputStream(org.apache.commons.compress.archivers.ArchiveEntry entry) throws IOException {
- return getInputStream(new TarArchiveEntry(entry.getName()));
+ public InputStream getInputStream(ArchiveEntry entry) throws IOException {
+ return getInputStream(
+ entry instanceof TarArchiveEntry ? (TarArchiveEntry) entry : new TarArchiveEntry(entry.getName()));
}
/**
- * Returns an {@link InputStream} with the given entries
- * contents. This {@link InputStream} may be closed: Nothing
- * happens in that case, because an actual close would invalidate
- * the underlying {@link TarArchiveInputStream}.
+ * Returns a member's logical contents; a caller-created header selects the first
+ * matching name. Reading the current ordinary member streams directly, whereas
+ * earlier or already-consumed contents may require an independent backward read.
+ * Hard-link payloads are cached once per data-bearing occurrence.
+ * Close this stream before requesting another; closing it does not close the reader.
+ *
+ * @param entry the member whose logical contents are requested
+ * @return the member's contents, resolving valid backward hard links
+ * @throws IOException if reading fails or the link is invalid
*/
public InputStream getInputStream(TarArchiveEntry entry) throws IOException {
- if (entry.equals((Object) currentEntry) && inputStream != null) {
- return new FilterInputStream(inputStream) {
+ TarArchiveEntry actual = findEntry(entry);
+ if (actual.isLink()) {
+ TarArchiveEntry target = index.resolve(actual);
+ if (linkPayloads == null) {
+ linkPayloads = new TarPayloads();
+ }
+ return java.nio.file.Files.newInputStream(linkPayloads.add(this, target));
+ }
+ return rawContents(actual);
+ }
- public void close() throws IOException {
- // Does nothing.
- }
- };
+ /** Uses the live payload when available, reserving a separate cursor for explicit replay. */
+ InputStream rawContents(TarArchiveEntry entry) throws IOException {
+ if (enumerationCursor != null && enumerationCursor.available(entry)) {
+ return enumerationCursor.contents();
}
- return getInputStream(entry, currentEntry);
+ int position = index.position(entry);
+ if (replayCursor != null && replayCursor.available(entry)) {
+ return replayCursor.contents();
+ }
+ if (replayCursor == null || replayCursor.position >= position) {
+ if (replayCursor != null) {
+ replayCursor.close();
+ }
+ replayCursor = new Cursor();
+ }
+ while (replayCursor.position < position) {
+ if (replayCursor.next() == null) {
+ throw new IOException("TAR changed while reading " + entry.getName());
+ }
+ }
+ return replayCursor.contents();
}
+ /** Opens the source; compressed subclasses supply their decompression stream here. */
protected InputStream getInputStream(File file) throws IOException {
return Streams.fileInputStream(file);
}
- private InputStream getInputStream(TarArchiveEntry entry, TarArchiveEntry currentEntry) throws IOException {
- if (currentEntry == null || inputStream == null) {
- // Search for the entry from the beginning of the file to the end.
- if (inputStream != null) {
- close();
- }
- open();
- if (!findEntry(entry, null)) {
- throw new IOException("Unknown entry: " + entry.getName());
- }
- } else {
- // Search for the entry from the current position to the end of the file.
- if (findEntry(entry, null)) {
- return getInputStream(entry);
- }
- close();
- open();
- if (!findEntry(entry, currentEntry)) {
- throw new IOException("No such entry: " + entry.getName());
- }
+ /** Owns one sequential decoder and records headers without consuming their payloads early. */
+ private final class Cursor implements Closeable {
+ private final TarArchiveInputStream input;
+ private int position = -1;
+ private TarArchiveEntry entry;
+ private boolean claimed;
+
+ /** Opens a decoder only when enumeration or a content lookup actually needs it. */
+ private Cursor() throws IOException {
+ input = new TarArchiveInputStream(bufferedInputStream(getInputStream(file)), "UTF8");
+ }
+
+ /** Advances one header and reuses its canonical occurrence record across replay cursors. */
+ private TarArchiveEntry next() throws IOException {
+ TarArchiveEntry next = input.getNextEntry();
+ entry = next == null ? null : index.record(++position, next);
+ claimed = false;
+ return entry;
}
- return getInputStream(entry);
- }
- private void open() throws IOException {
- inputStream = new TarArchiveInputStream(bufferedInputStream(getInputStream(file)), "UTF8");
+ /** Determines whether the current payload has not yet been handed to a consumer. */
+ private boolean available(TarArchiveEntry requested) {
+ return entry == requested && !claimed;
+ }
+
+ /** Gives the caller a non-owning stream while preserving the cursor for the next header. */
+ private InputStream contents() {
+ claimed = true;
+ return new FilterInputStream(input) {
+ /** Closing a member must not close the decoder used by later members. */
+ @Override
+ public void close() {}
+ };
+ }
+
+ /** Releases this cursor's decoder and source handle. */
+ @Override
+ public void close() throws IOException {
+ input.close();
+ }
}
- private boolean findEntry(TarArchiveEntry entry, TarArchiveEntry currentEntry) throws IOException {
- for (; ; ) {
- this.currentEntry = inputStream.getNextEntry();
- if (this.currentEntry == null || (currentEntry != null && this.currentEntry.equals(currentEntry))) {
- return false;
- }
- if (this.currentEntry.equals(entry)) {
- return true;
- }
+ /** Releases both cursors and cached payloads, including when any individual close fails. */
+ @Override
+ public void close() throws IOException {
+ try (Cursor enumeration = enumerationCursor;
+ Cursor replay = replayCursor;
+ TarPayloads payloads = linkPayloads) {
+ // Resource ownership is independent, so every close must be attempted.
+ } finally {
+ enumerationCursor = null;
+ replayCursor = null;
+ linkPayloads = null;
+ index = new TarEntryIndex();
}
}
}
diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarPayloads.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarPayloads.java
new file mode 100644
index 00000000..0e25a661
--- /dev/null
+++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarPayloads.java
@@ -0,0 +1,91 @@
+/*
+ * 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.archiver.tar;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+
+/** Owns temporary payloads, storing each data-bearing occurrence only once. */
+final class TarPayloads implements Closeable {
+ private final Path directory;
+ private final Map files = new IdentityHashMap<>();
+
+ /** Creates a private cache only when a caller requests hard-link contents. */
+ TarPayloads() throws IOException {
+ directory = Files.createTempDirectory("plexus-tar-");
+ }
+
+ /** Copies one logical payload, retaining it only after the copy and stream closures succeed. */
+ Path add(TarFile archive, TarArchiveEntry entry) throws IOException {
+ Path existing = files.get(entry);
+ if (existing != null) {
+ return existing;
+ }
+ Path target = Files.createTempFile(directory, "payload-", "");
+ boolean complete = false;
+ try {
+ try (InputStream input = archive.rawContents(entry);
+ OutputStream output = Files.newOutputStream(target)) {
+ input.transferTo(output);
+ }
+ files.put(entry, target);
+ complete = true;
+ return target;
+ } finally {
+ // Even a stream-close failure must not leave an untracked temporary payload.
+ if (!complete) {
+ Files.deleteIfExists(target);
+ }
+ }
+ }
+
+ /** Removes anchors on success and failure without traversing extracted output directories. */
+ @Override
+ public void close() throws IOException {
+ IOException failure = null;
+ for (Path path : files.values()) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ try {
+ Files.deleteIfExists(directory);
+ } catch (IOException e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+}
diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarResource.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarResource.java
index dc6617d4..785d6e95 100644
--- a/src/main/java/org/codehaus/plexus/archiver/tar/TarResource.java
+++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarResource.java
@@ -4,16 +4,20 @@
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
import java.net.URL;
+import java.util.Arrays;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.codehaus.plexus.components.io.attributes.PlexusIoResourceAttributes;
import org.codehaus.plexus.components.io.attributes.SimpleResourceAttributes;
+import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier;
import org.codehaus.plexus.components.io.functions.ResourceAttributeSupplier;
import org.codehaus.plexus.components.io.resources.AbstractPlexusIoResource;
import org.codehaus.plexus.components.io.resources.PlexusIoResource;
-public class TarResource extends AbstractPlexusIoResource implements ResourceAttributeSupplier {
+public class TarResource extends AbstractPlexusIoResource
+ implements ResourceAttributeSupplier, HardLinkIdentitySupplier {
private final TarFile tarFile;
@@ -42,6 +46,7 @@ private static long getLastModifiedTime(TarArchiveEntry entry) {
@Override
public synchronized PlexusIoResourceAttributes getAttributes() {
if (attributes == null) {
+ TarArchiveEntry entry = contentEntry();
attributes = new SimpleResourceAttributes(
entry.getUserId(), entry.getUserName(), entry.getGroupId(), entry.getGroupName(), entry.getMode());
}
@@ -53,6 +58,45 @@ public synchronized void setAttributes(PlexusIoResourceAttributes attributes) {
this.attributes = attributes;
}
+ /** Returns the resolved data size while retaining zero in the actual TAR link header. */
+ @Override
+ public long getSize() {
+ return entry.isLink() ? contentEntry().getRealSize() : super.getSize();
+ }
+
+ /** Uses data-bearing metadata so aliases of one inode have compatible archive attributes. */
+ @Override
+ public long getLastModified() {
+ return entry.isLink() ? getLastModifiedTime(contentEntry()) : super.getLastModified();
+ }
+
+ /** Resolves link metadata without changing the archive enumeration cursor. */
+ private TarArchiveEntry contentEntry() {
+ try {
+ return entry.isLink() ? tarFile.resolve(entry) : entry;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Supplies an identity scoped to this reader and the exact data-bearing occurrence.
+ * Subclasses must explicitly supply an identity that guarantees their actual contents.
+ * @return the payload identity, or null for subclasses, directories, and symbolic links
+ * @throws IOException if a hard-link relationship is invalid
+ */
+ @Override
+ public Object getHardLinkIdentity() throws IOException {
+ // An overriding content supplier may expose different bytes from this archive occurrence.
+ if (getClass() != TarResource.class) {
+ return null;
+ }
+ TarArchiveEntry target = tarFile.resolve(entry);
+ return TarEntryIndex.isRegular(target)
+ ? Arrays.asList(tarFile, tarFile.index().position(target))
+ : null;
+ }
+
@Override
public URL getURL() throws IOException {
return null;
diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java
index de73c106..57d60413 100644
--- a/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java
+++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java
@@ -21,10 +21,21 @@
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.UUID;
import java.util.zip.GZIPInputStream;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
import org.apache.commons.compress.compressors.snappy.FramedSnappyCompressorInputStream;
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream;
@@ -33,6 +44,7 @@
import org.codehaus.plexus.archiver.ArchiverException;
import org.codehaus.plexus.archiver.util.Streams;
import org.codehaus.plexus.components.io.filemappers.FileMapper;
+import org.codehaus.plexus.util.FileUtils;
import static org.codehaus.plexus.archiver.util.Streams.bufferedInputStream;
import static org.codehaus.plexus.archiver.util.Streams.fileInputStream;
@@ -54,6 +66,39 @@ public TarUnArchiver(File sourceFile) {
*/
private UntarCompressionMethod compression = UntarCompressionMethod.NONE;
+ private boolean failOnSymlinkTraversal;
+
+ /** The destination already checked for the current call to a subclass extraction hook. */
+ private MappedEntry mappedEntry;
+
+ /** Retains original hook arguments alongside the destination computed for this occurrence. */
+ private record MappedEntry(String name, FileMapper[] mappers, String destination) {}
+
+ /**
+ * Controls rejection of intermediate symbolic links in selected, mapped extraction paths.
+ * The default is {@code false}, allowing contained directory symlinks as GNU tar does.
+ * When enabled, both destinations and hard-link targets are checked before overwrite decisions;
+ * an offending entry throws {@link ArchiverException}, leaving earlier extracted entries in place.
+ * The configured destination directory is trusted, and ordinary symbolic-link entries remain allowed.
+ * Existing destination-containment checks apply with either setting.
+ *
+ * @param failOnSymlinkTraversal whether intermediate symbolic links cause extraction to fail
+ * @since 5.0.0
+ */
+ public void setFailOnSymlinkTraversal(boolean failOnSymlinkTraversal) {
+ this.failOnSymlinkTraversal = failOnSymlinkTraversal;
+ }
+
+ /**
+ * Returns whether intermediate symbolic links cause TAR extraction to fail.
+ *
+ * @return {@code true} when symlink traversal is rejected; {@code false} by default
+ * @since 5.0.0
+ */
+ public boolean isFailOnSymlinkTraversal() {
+ return failOnSymlinkTraversal;
+ }
+
/**
* Set decompression algorithm to use; default=none.
*
@@ -89,34 +134,249 @@ protected void execute(String path, File outputDirectory) {
execute(new File(path), getDestDirectory(), getFileMappers());
}
+ /** Extracts each selected member as it arrives, resolving links only against earlier members. */
protected void execute(File sourceFile, File destDirectory, FileMapper[] fileMappers) throws ArchiverException {
- try {
- getLogger().info("Expanding: " + sourceFile + " into " + destDirectory);
- TarFile tarFile = new TarFile(sourceFile);
- try (TarArchiveInputStream tis = new TarArchiveInputStream(
- decompress(compression, sourceFile, bufferedInputStream(fileInputStream(sourceFile))))) {
- TarArchiveEntry te;
- while ((te = tis.getNextEntry()) != null) {
- TarResource fileInfo = new TarResource(tarFile, te);
- if (isSelected(te.getName(), fileInfo)) {
- final String symlinkDestination = te.isSymbolicLink() ? te.getLinkName() : null;
+ getLogger().info("Expanding: " + sourceFile + " into " + destDirectory);
+ try (TarFile archive = newTarFile(sourceFile)) {
+ Map mappedNames = new IdentityHashMap<>();
+ Enumeration entries = archive.getEntries();
+ while (entries.hasMoreElements()) {
+ TarArchiveEntry entry = (TarArchiveEntry) entries.nextElement();
+ if (!isSelected(entry.getName(), new TarResource(archive, entry))) {
+ continue;
+ }
+ String name = mappedName(entry, fileMappers, mappedNames);
+ if (entry.isLink()) {
+ // Validate the source relationship before consulting the current destination file.
+ archive.resolve(entry);
+ TarArchiveEntry target = archive.index().linkTarget(entry);
+ extractHardLink(destDirectory, entry, name, mappedName(target, fileMappers, mappedNames));
+ } else {
+ // Check the mapped path before opening contents or dispatching to subclass extraction hooks.
+ checkSymlinkTraversal(destDirectory, name, entry.getName(), false);
+ MappedEntry previous = mappedEntry;
+ mappedEntry = new MappedEntry(entry.getName(), fileMappers, name);
+ try (InputStream contents = archive.getInputStream(entry)) {
extractFile(
sourceFile,
destDirectory,
- tis,
- te.getName(),
- te.getModTime(),
- te.isDirectory(),
- te.getMode() != 0 ? te.getMode() : null,
- symlinkDestination,
+ contents,
+ entry.getName(),
+ entry.getModTime(),
+ entry.isDirectory(),
+ entry.getMode() != 0 ? entry.getMode() : null,
+ entry.isSymbolicLink() ? entry.getLinkName() : null,
fileMappers);
+ } finally {
+ // A failed, skipped or nested hook must not leak its mapped destination into another call.
+ mappedEntry = previous;
}
}
- getLogger().debug("expand complete");
}
- } catch (IOException ioe) {
- throw new ArchiverException("Error while expanding " + sourceFile.getAbsolutePath(), ioe);
+ } catch (IOException | UncheckedIOException e) {
+ throw new ArchiverException(
+ "Error while expanding " + sourceFile.getAbsolutePath() + ": " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Extracts an ordinary entry while preserving the original-name and mapper arguments seen by subclasses.
+ * Calls to {@code super.extractFile(...)} with those arguments reuse the already checked destination,
+ * so a stateful mapper is not invoked again. Subclasses supplying different arguments are mapped normally.
+ *
+ * @param source the source archive
+ * @param directory the extraction directory
+ * @param contents the entry contents
+ * @param name the original archive member name
+ * @param date the entry timestamp
+ * @param isDirectory whether the entry is a directory
+ * @param mode the entry permissions, or null
+ * @param symlink the symbolic-link target, or null
+ * @param fileMappers the configured file mappers
+ * @throws IOException if mapping checks or extraction fail
+ * @throws ArchiverException if the destination is rejected
+ */
+ @Override
+ protected void extractFile(
+ File source,
+ File directory,
+ InputStream contents,
+ String name,
+ Date date,
+ boolean isDirectory,
+ Integer mode,
+ String symlink,
+ FileMapper[] fileMappers)
+ throws IOException {
+ String destination =
+ mappedEntry != null && mappedEntry.name().equals(name) && mappedEntry.mappers() == fileMappers
+ ? mappedEntry.destination()
+ : applyFileMappers(name, fileMappers);
+ // A subclass can alter arguments or filesystem state before delegating, so recheck the actual destination.
+ checkSymlinkTraversal(directory, destination, name, false);
+ super.extractFile(source, directory, contents, destination, date, isDirectory, mode, symlink, null);
+ }
+
+ /**
+ * Creates the streaming reader using the configured compression method.
+ * @param sourceFile the archive to extract
+ * @return a reader whose enumeration and explicit content lookups use the same decoder configuration
+ */
+ protected TarFile newTarFile(File sourceFile) {
+ return new TarFile(sourceFile) {
+ /** Applies decompression when a cursor opens, without any preliminary scan. */
+ @Override
+ protected InputStream getInputStream(File file) throws IOException {
+ return decompress(compression, file, bufferedInputStream(fileInputStream(file)));
+ }
+ };
+ }
+
+ /** Maps each occurrence once, including an excluded target when a selected link needs its path. */
+ private String mappedName(
+ TarArchiveEntry entry, FileMapper[] fileMappers, Map mappedNames) {
+ return mappedNames.computeIfAbsent(entry, ignored -> applyFileMappers(entry.getName(), fileMappers));
+ }
+
+ /** Applies a mapper chain in order when no mapped result is available for these hook arguments. */
+ private static String applyFileMappers(String name, FileMapper[] fileMappers) {
+ if (fileMappers != null) {
+ for (FileMapper mapper : fileMappers) {
+ name = mapper.getMappedFileName(name);
+ }
+ }
+ return name;
+ }
+
+ /**
+ * Checks existing parent components without following links, including links preceding a later {@code ..}.
+ * Missing parents are allowed because extraction creates them after validation.
+ */
+ private void checkSymlinkTraversal(File directory, String name, String entryName, boolean linkTarget)
+ throws IOException {
+ if (!failOnSymlinkTraversal) {
+ return;
+ }
+ Path root = directory.toPath().toAbsolutePath();
+ Path canonicalRoot = directory.getCanonicalFile().toPath();
+ // FileUtils accepts either separator in absolute names, but keeps relative names platform-native.
+ Path portable = Path.of(name.replace('/', File.separatorChar).replace('\\', File.separatorChar));
+ // Resolve the trusted directory first: a symlink followed by '..' can change which tree owns its children.
+ Path path = portable.isAbsolute() ? portable : canonicalRoot.resolve(Path.of(name));
+ int suffixStart = portable.isAbsolute() ? trustedRootPrefixLength(portable, root) : -1;
+ if (suffixStart >= 0) {
+ // Preserve the untrusted suffix without relativize(), which would collapse its parent components.
+ path = canonicalRoot;
+ for (int i = suffixStart; i < portable.getNameCount(); i++) {
+ path = path.resolve(portable.getName(i));
+ }
+ }
+ Path component = path.getRoot();
+ for (int i = 0; i < path.getNameCount() - 1; i++) {
+ // Normalize one component at a time: any preceding symlink was checked before a parent can erase it.
+ component = component.resolve(path.getName(i)).normalize();
+ // Only the actual trusted root and its ancestors are exempt from traversal checks.
+ if (canonicalRoot.startsWith(component)) {
+ continue;
+ }
+ try {
+ if (Files.readAttributes(component, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)
+ .isSymbolicLink()) {
+ throw new ArchiverException("Cannot extract TAR entry '" + entryName + "': "
+ + (linkTarget ? "hard-link target" : "destination") + " '" + name
+ + "' traverses symbolic link '" + component + "'");
+ }
+ } catch (NoSuchFileException ignored) {
+ // Continue so a later parent component cannot hide a different, existing symlink.
+ }
+ }
+ }
+
+ /**
+ * Matches an absolute configured root while ignoring harmless dot components in either spelling.
+ * Parent components are compared literally so symlinks preceding {@code ..} retain their meaning.
+ *
+ * @param path the absolute mapped path whose suffix must remain unchanged
+ * @param root the absolute configured destination before filesystem resolution
+ * @return the index of the first suffix component, or {@code -1} when the trusted prefix does not match
+ */
+ private static int trustedRootPrefixLength(Path path, Path root) {
+ if (!root.getRoot().equals(path.getRoot())) {
+ return -1;
+ }
+ int next = 0;
+ for (Path component : root) {
+ if (component.toString().equals(".")) {
+ continue;
+ }
+ // Skip dots only while matching the trusted prefix; preserve every component of the remaining suffix.
+ while (next < path.getNameCount() && path.getName(next).toString().equals(".")) {
+ next++;
+ }
+ if (next == path.getNameCount() || !component.equals(path.getName(next))) {
+ return -1;
+ }
+ next++;
+ }
+ return next;
+ }
+
+ /** Checks the traversal policy and both lexical and resolved containment before hard-link operations. */
+ private Path checkedOutput(File directory, String name, String entryName, boolean linkTarget) throws IOException {
+ checkSymlinkTraversal(directory, name, entryName, linkTarget);
+ Path root = directory.getCanonicalFile().toPath();
+ Path path =
+ FileUtils.resolveFile(directory, name).toPath().toAbsolutePath().normalize();
+ Path resolved = path.toFile().getCanonicalFile().toPath();
+ // FileUtils resolves the output against the actual root, including symlinks in the configured directory.
+ if (!path.startsWith(root) || !resolved.startsWith(root)) {
+ throw new ArchiverException("Entry is outside of the target directory (" + name + ")");
+ }
+ return path;
+ }
+
+ /** Links the current mapped target, following the filesystem semantics of command-line tar. */
+ private void extractHardLink(File directory, TarArchiveEntry entry, String name, String targetName)
+ throws IOException {
+ Path output = checkedOutput(directory, name, entry.getName(), false);
+ Path target = checkedOutput(directory, targetName, entry.getName(), true);
+ if (output.equals(target)
+ || output.toFile().getCanonicalFile().equals(target.toFile().getCanonicalFile())) {
+ throw new IOException("Self-referencing TAR hard-link output: " + name);
+ }
+ if (Files.isSymbolicLink(output) || Files.isDirectory(output, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Cannot replace non-regular TAR hard-link output: " + name);
}
+ if (!shouldExtractEntry(directory, output.toFile(), name, entry.getModTime())) {
+ return;
+ }
+ // Excluded or retained targets are not recovered from the archive; their current path must exist.
+ if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("TAR hard-link target is not an existing regular file: " + targetName);
+ }
+ Files.createDirectories(output.getParent());
+ checkedOutput(directory, name, entry.getName(), false);
+ checkedOutput(directory, targetName, entry.getName(), true);
+ Path temporary = output.getParent().resolve(".plexus-link-" + UUID.randomUUID());
+ try {
+ // Build the replacement first so link-creation failure leaves an existing output untouched.
+ createHardLink(temporary, target);
+ Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException | UnsupportedOperationException e) {
+ throw new IOException("Cannot create TAR hard link " + name, e);
+ } finally {
+ Files.deleteIfExists(temporary);
+ }
+ }
+
+ /**
+ * Creates a filesystem hard link with no copy fallback.
+ * @param link new directory entry
+ * @param existing the earlier member's current mapped regular file
+ * @throws IOException if the filesystem cannot create the link
+ */
+ protected void createHardLink(Path link, Path existing) throws IOException {
+ Files.createLink(link, existing);
}
/**
diff --git a/src/site/markdown/hard-links.md b/src/site/markdown/hard-links.md
new file mode 100644
index 00000000..ca2bb0b2
--- /dev/null
+++ b/src/site/markdown/hard-links.md
@@ -0,0 +1,221 @@
+# TAR hard links
+
+A hard link is one more name for the same file. In a TAR archive, a hard-link
+member contains a reference to a target member. A data member contains the
+contents of a regular file.
+
+## Archive creation
+
+To enable hard-link preservation, call `TarArchiver.setPreserveHardLinks(true)`
+before you make the archive:
+
+```java
+TarArchiver archiver = new TarArchiver();
+archiver.setPreserveHardLinks(true);
+archiver.addFileSet(DefaultFileSet.fileSet(inputDirectory));
+archiver.setDestFile(outputArchive);
+archiver.createArchive();
+```
+
+The default value is `false`. With this option enabled, resources with the same
+identity and output metadata share one data member. The archiver writes
+subsequent names as hard-link members that contain the target name in the
+archive. Compressed TAR archivers also have this option.
+
+For hard-link preservation, the resource must implement the optional Plexus IO
+`HardLinkIdentitySupplier` interface. A local regular file gives an identity if
+it supplies the source file contents and the file system gives a file key. The
+archiver writes a full data member for each resource in these conditions:
+
+- The resource uses a custom content supplier.
+- A stream transformation changes the contents.
+- The resource has no identity.
+- The output metadata is different from the target metadata.
+
+A name mapper that changes only the name does not change the identity. It
+changes the target name in the archive. The archiver writes symbolic links as
+symbolic-link members. If the archiver truncates long names, it disables
+hard-link preservation. A truncated target name can identify more than one
+member.
+
+To support hard-link preservation, a subclass of `PlexusIoFileResource` or
+`TarResource` must supply its own hard-link identity. Resources with that
+identity must have the same contents. A subclass can change the contents even if
+it uses the same source file or archive member.
+
+The archiver continues hard-link preservation when archive paths go through
+symbolic links. GNU tar and bsdtar do the same. A subsequent write through a
+directory symbolic link can replace the file at a hard-link target path.
+
+For example, an archive contains these members in this sequence:
+
+1. A data member named `real/file`.
+2. A symbolic-link member named `redirect`, with `real` as its target.
+3. A data member named `redirect/file`, with different contents.
+4. A hard-link member named `alias`, with `real/file` as its target.
+
+With the default extraction settings, `redirect/file` replaces `real/file`
+through the symbolic link. The hard link named `alias` then has the replacement
+contents. With hard-link preservation disabled, the archiver stores the contents
+of each resource in a different data member. The symbolic link does not disable
+hard-link preservation for other groups of resources.
+
+## Streaming extraction
+
+The extractor reads the TAR archive one time in a forward direction. It writes
+each selected member when it reads that member. It does not scan the headers
+first or store file contents in temporary files before extraction.
+
+A hard-link member must reference a previous data member or a chain of previous
+hard-link members that ends at a data member. The extractor rejects a selected
+member in these conditions:
+
+- The link references a subsequent member.
+- The target member is missing.
+- The link references itself.
+- The target is not a regular file or a hard link to a regular file.
+- The path goes outside the destination directory.
+
+The extractor does not search subsequent members or try the rejected members
+again.
+
+The extractor uses different records for source archive names and mapped
+destination paths. A hard link uses the regular file at the mapped target path.
+GNU tar and bsdtar do the same. If a selection rule excludes the target, the
+hard link uses the target file in the destination directory. If the overwrite
+policy keeps that file, the hard link also uses it. If that file is missing,
+extraction stops with an error.
+
+The extractor does not read the archive again to get the contents of an excluded
+target. It does not make a destination file for an excluded target.
+
+If a source name occurs more than one time, a hard link references the last
+member with that name before the link. The extractor writes members in archive
+sequence. If mappers send different names to one destination path, subsequent
+hard links use the contents at that path at that time. The extractor does not
+change the timestamps or permissions of the target inode when it makes a hard
+link. It rejects a hard link with the same destination path as its target. This
+is the bsdtar behavior, which is different from GNU tar.
+
+If the extractor cannot make a hard link, it gives an error. It does not copy
+the file contents as an alternative. It makes a replacement hard link before it
+replaces an output name. After an error, previous output files can stay in the
+destination directory.
+
+The protected `AbstractUnArchiver.extractFile` method signature stays available
+for members that are not hard links.
+
+### Optional checks for symbolic links
+
+By default, the extractor lets paths go through directory symbolic links to
+locations inside the destination directory. GNU tar does the same. To reject
+these paths, set `failOnSymlinkTraversal` to `true` before you call `extract()`:
+
+```java
+TarUnArchiver extractor = new TarUnArchiver(inputArchive);
+extractor.setDestDirectory(outputDirectory);
+extractor.setFailOnSymlinkTraversal(true);
+extractor.extract();
+```
+
+The default value of `failOnSymlinkTraversal` is `false`. Compressed TAR
+extractors also have this setting. Assembly's `archiverConfig` configures the
+archiver, not the extractor. The `UnArchiver` interface and the provider
+configuration interfaces do not have this TAR option.
+
+With this setting enabled, the extractor rejects intermediate symbolic links in
+selected, mapped output paths and hard-link target paths. An intermediate
+symbolic link is a symbolic link before the last component of a path. The checks
+include symbolic links from before extraction and those that previous members
+added. The extractor does these checks before overwrite decisions or file system
+changes.
+
+The extractor skips excluded members. For a selected hard link, it checks the
+mapped target path even if a selection rule excludes the target. If a path has
+an intermediate symbolic link, the extractor immediately throws
+`ArchiverException`. The error message gives the member path and the symbolic
+link path. Previous output files stay at their destination paths.
+
+This setting rejects intermediate symbolic links. This is also the default
+bsdtar behavior. Unlike bsdtar, the extractor gives the error immediately. The
+extractor does not use the other bsdtar path policies with this setting.
+
+The extractor trusts the configured destination directory, even if its path goes
+through a symbolic link. It resolves that directory before it checks paths
+inside the directory. This is also true when the configured path contains a
+symbolic link followed by `..`. GNU tar and bsdtar also resolve their `-C`
+directory before they extract members.
+
+More `.` components do not change the trusted directory. This is true for the
+configured root path and for absolute mapped paths that start at that root. The
+extractor also checks intermediate symbolic links before parent components in
+member paths.
+
+The extractor accepts symbolic-link members when the setting is `true` or
+`false`. The existing rules control replacement of the last path component. The
+two settings prevent extraction outside the destination directory. The checks
+use the file system paths that the extractor finds as it reads each member. An
+archive scan, a second read, and temporary storage of file contents are not
+necessary for these checks.
+
+## Archived file sets and content access
+
+`TarFile` reads members as the caller requests them. It records each header when
+it reads that header. For the current member, it reads contents from the same
+stream unless the member is a hard link. For backward hard links, it gets
+metadata from the recorded headers without a read of the file contents.
+
+To read contents from a previous member, `TarFile` can open a different cursor
+that reads the archive again. The same mechanism can read contents that a caller
+read before. For a compressed archive, the cursor also decompresses the data
+again. The cursor does not change the position of the member enumeration.
+
+`TarFile` caches one copy of the requested hard-link contents for each data
+member. Subsequent hard-link members that reference the same data member use the
+same cache. Selectors that read file contents use this mechanism. During
+extraction, a second read or file contents cache is necessary only if a caller
+requests these contents.
+
+`TarResource` gives the size, bytes, and metadata of the data member for a
+backward hard link that meets the target requirements. A caller can select only
+the hard-link member from an archived file set. A caller can also copy TAR
+members to a different TAR archive or to a ZIP archive. These content requests
+get bytes from the archive, not from the destination file system. The size in
+the TAR hard-link header stays zero.
+
+Do not open more than one content stream on the same `TarFile` at a time. Do not
+use more than one member enumeration on the same `TarFile` at a time.
+
+## Temporary storage
+
+The cache stores one copy of the requested hard-link contents for each data
+member. The number of hard links to that data member does not change this rule.
+The `close()` method deletes temporary files from the cache.
+
+After you use a reader or resource collection that you manage directly, close
+it.
+
+The archiver owns resource collections that a caller adds through
+`addArchivedFileSet()` or `addResources()`. It closes these collections and
+their archive readers when `createArchive()` completes or stops with an error.
+It tries to close each iterator and wrapped collection even if a different close
+operation gives an error.
+
+## Maven Assembly configuration
+
+To enable hard-link preservation in Maven Assembly, use this archiver
+configuration:
+
+```xml
+
+ true
+
+```
+
+The identity API from the companion Plexus IO `3.7.1-SNAPSHOT` change is
+necessary for hard-link preservation. If Assembly directly depends on a
+previous Plexus IO version, override `plexus-archiver` and `plexus-io` in the
+plugin dependencies.
+
+Until releases contain the two changes, build the companion snapshot locally.
+When releases contain the two changes, use those releases.
diff --git a/src/site/site.xml b/src/site/site.xml
index 63f169c0..4a4e0617 100644
--- a/src/site/site.xml
+++ b/src/site/site.xml
@@ -4,6 +4,7 @@