From 4b2cc87d36206bfed3cb058c76d154fcf6c21597 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sun, 13 Sep 2026 21:57:07 +0200 Subject: [PATCH 1/2] Resolve TAR hard links independently of extraction paths --- pom.xml | 2 +- .../plexus/archiver/AbstractArchiver.java | 58 +- .../plexus/archiver/tar/TarArchiver.java | 115 +++- .../plexus/archiver/tar/TarEntryIndex.java | 158 ++++++ .../codehaus/plexus/archiver/tar/TarFile.java | 274 +++++---- .../plexus/archiver/tar/TarPayloads.java | 91 +++ .../plexus/archiver/tar/TarResource.java | 46 +- .../plexus/archiver/tar/TarUnArchiver.java | 250 ++++++++- src/site/markdown/hard-links.md | 146 +++++ src/site/site.xml | 1 + .../plexus/archiver/AbstractArchiverTest.java | 69 +++ .../tar/TarArchivedFileSetCleanupTest.java | 115 ++++ .../archiver/tar/TarHardLinkArchiverTest.java | 496 +++++++++++++++++ .../plexus/archiver/tar/TarHardLinkTest.java | 373 +++++++++++++ .../plexus/archiver/tar/TarStreamingTest.java | 356 ++++++++++++ .../archiver/tar/TarSymlinkTraversalTest.java | 518 ++++++++++++++++++ 16 files changed, 2914 insertions(+), 154 deletions(-) create mode 100644 src/main/java/org/codehaus/plexus/archiver/tar/TarEntryIndex.java create mode 100644 src/main/java/org/codehaus/plexus/archiver/tar/TarPayloads.java create mode 100644 src/site/markdown/hard-links.md create mode 100644 src/test/java/org/codehaus/plexus/archiver/tar/TarArchivedFileSetCleanupTest.java create mode 100644 src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkArchiverTest.java create mode 100644 src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java create mode 100644 src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java create mode 100644 src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java diff --git a/pom.xml b/pom.xml index 7a3e710ef..a5fb84a6c 100644 --- a/pom.xml +++ b/pom.xml @@ -69,7 +69,7 @@ org.codehaus.plexus plexus-io - 3.7.0 + 3.7.1-SNAPSHOT org.apache.commons diff --git a/src/main/java/org/codehaus/plexus/archiver/AbstractArchiver.java b/src/main/java/org/codehaus/plexus/archiver/AbstractArchiver.java index a393601b3..dfb8d2d02 100755 --- a/src/main/java/org/codehaus/plexus/archiver/AbstractArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/AbstractArchiver.java @@ -23,7 +23,6 @@ import java.io.Closeable; import java.io.File; import java.io.IOException; -import java.io.UncheckedIOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.attribute.FileTime; @@ -473,6 +472,9 @@ public boolean hasNext() { try { ioResourceIter = currentResourceCollection.resources.getResources(); + // Register ownership before reading, since iteration can fail before + // exhaustion. + addCloseable(ioResourceIter); } catch (final IOException e) { throw new ArchiverException(e.getMessage(), e); } @@ -487,12 +489,6 @@ public boolean hasNext() { final PlexusIoResource resource = (PlexusIoResource) ioResourceIter.next(); nextEntry = asArchiveEntry(currentResourceCollection, resource); } else { - // this will leak handles in the IO iterator if the iterator is not fully consumed. - // alternately we'd have to make this method return a Closeable iterator back - // to the client and ditch the whole issue onto the client. - // this does not really make any sense either, might equally well change the - // api into something that is not broken by design. - addCloseable(ioResourceIter); ioResourceIter = null; } } @@ -552,20 +548,19 @@ private String normalizedForDuplicateCheck(ArchiveEntry entry) { }; } + /** Reaches the owning collection through permission and proxy wrappers before releasing its resources. */ private static void closeIfCloseable(Object resource) throws IOException { + if (resource instanceof AddedResourceCollection collection) { + resource = collection.resources; + } + while (resource instanceof PlexusIoProxyResourceCollection collection) { + resource = collection.getSrc(); + } if (resource instanceof Closeable closeable) { closeable.close(); } } - private static void closeQuietlyIfCloseable(Object resource) { - try { - closeIfCloseable(resource); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - @Override public File getDestFile() { return destFile; @@ -832,25 +827,30 @@ private void addCloseable(Object maybeCloseable) { } } - private void closeIterators() { - for (Closeable closeable : closeables) { - closeQuietlyIfCloseable(closeable); - } - } - protected abstract void close() throws IOException; + /** Releases iterators and owning collections on success or failure, attempting every registered close. */ protected void cleanUp() throws IOException { - closeIterators(); - - for (Object resource : resources) { - if (resource instanceof PlexusIoProxyResourceCollection collection) { - resource = collection.getSrc(); + List pending = new ArrayList<>(closeables); + pending.addAll(resources); + // Detach ownership before closing so a failure cannot retain readers across archive operations. + closeables.clear(); + resources.clear(); + IOException failure = null; + for (Object resource : pending) { + try { + closeIfCloseable(resource); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else if (failure != e) { + failure.addSuppressed(e); + } } - - closeIfCloseable(resource); } - resources.clear(); + if (failure != null) { + throw failure; + } } protected abstract void execute() throws ArchiverException, IOException; diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarArchiver.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarArchiver.java index 2091eaf8b..05764e79d 100644 --- a/src/main/java/org/codehaus/plexus/archiver/tar/TarArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarArchiver.java @@ -22,10 +22,16 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; import java.util.zip.GZIPOutputStream; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.tar.TarConstants; import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; import org.apache.commons.compress.compressors.snappy.FramedSnappyCompressorOutputStream; import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream; @@ -38,6 +44,7 @@ import org.codehaus.plexus.archiver.util.ResourceUtils; import org.codehaus.plexus.archiver.util.Streams; import org.codehaus.plexus.components.io.attributes.PlexusIoResourceAttributes; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; import org.codehaus.plexus.components.io.functions.SymlinkDestinationSupplier; import org.codehaus.plexus.components.io.resources.PlexusIoResource; import org.codehaus.plexus.util.IOUtil; @@ -64,6 +71,26 @@ public class TarArchiver extends AbstractArchiver { private TarArchiveOutputStream tOut; + private boolean preserveHardLinks; + private final Map hardLinkTargets = new HashMap<>(); + private final Map writtenNames = new HashMap<>(); + + /** + * Enables preservation of known, untransformed hard-link identities; default false. + * Unknown identities and entries with different output metadata are written in full. + * Like GNU tar, preservation continues when output paths traverse archived symbolic links; + * extraction then links to the target path's current contents. + * @param preserveHardLinks whether eligible aliases share one TAR payload + */ + public void setPreserveHardLinks(boolean preserveHardLinks) { + this.preserveHardLinks = preserveHardLinks; + } + + /** Returns whether hard-link preservation was explicitly enabled. */ + public boolean isPreserveHardLinks() { + return preserveHardLinks; + } + /** * Set how to handle long files, those with a path>100 chars. * Optional, default=warn. @@ -102,6 +129,9 @@ public void setCompression(TarCompressionMethod mode) { @Override protected void execute() throws ArchiverException, IOException { + // Identity and target names are meaningful only within the archive currently being written. + hardLinkTargets.clear(); + writtenNames.clear(); if (!checkForced()) { return; } @@ -285,25 +315,102 @@ protected void tarFile(ArchiveEntry entry, TarArchiveOutputStream tOut, String v te.setGroupId(groupId); } + HardLinkKey identity = hardLinkKey(entry, te); + String outputName = te.getName(); + String outputKey = normalizedOutputName(outputName); + // Even an ineligible link target can replace an earlier destination through a contained parent path. + HardLinkKey replaced = writtenNames.remove(outputKey); + if (replaced != null) { + hardLinkTargets.remove(replaced); + } + String target = identity == null ? null : hardLinkTargets.get(identity); + if (target != null) { + te = hardLinkEntry(te, target); + } tOut.putArchiveEntry(te); try { - if (entry.getResource().isFile() && !(entry.getType() == ArchiveEntry.SYMLINK)) { + if (entry.getResource().isFile() && !te.isSymbolicLink() && !te.isLink()) { fIn = entry.getInputStream(); Streams.copyFullyDontCloseOutput(fIn, tOut, "xAR"); } - } catch (Throwable e) { - getLogger().warn("When creating tar entry", e); } finally { tOut.closeArchiveEntry(); } + // Failed or omitted entries must never become targets of later link headers. + if (identity != null && target == null) { + hardLinkTargets.put(identity, outputName); + writtenNames.put(outputKey, identity); + } } finally { IOUtil.close(fIn); } } + /** + * Normalizes destination paths for target invalidation, including contained parent components. + * Emitted member names and the stricter eligibility rules for hard-link targets remain separate. + */ + private static String normalizedOutputName(String name) { + Deque components = new ArrayDeque<>(); + for (String component : name.split("/")) { + if (component.equals("..") + && !components.isEmpty() + && !components.peekLast().equals("..")) { + components.removeLast(); + } else if (!component.isEmpty() && !component.equals(".")) { + components.addLast(component); + } + } + return String.join("/", components); + } + + /** Returns an identity only when preservation is enabled and the resource guarantees its bytes. */ + private HardLinkKey hardLinkKey(ArchiveEntry entry, TarArchiveEntry header) throws IOException { + String name; + if (!preserveHardLinks + || longFileMode.isTruncateMode() + || entry.getType() != ArchiveEntry.FILE + || (name = header.getName()).startsWith("/") + || name.contains("\\") + || name.contains(":") + || Arrays.asList(name.split("/")).contains("..") + || !(entry.getResource() instanceof HardLinkIdentitySupplier supplier)) { + return null; + } + Object identity = supplier.getHardLinkIdentity(); + return identity == null + ? null + : new HardLinkKey( + identity, + header.getMode(), + header.getLongUserId(), + header.getLongGroupId(), + header.getUserName(), + header.getGroupName(), + header.getModTime().getTime(), + header.getSize()); + } + + /** Constructs a zero-payload link while retaining the effective metadata already calculated. */ + private TarArchiveEntry hardLinkEntry(TarArchiveEntry original, String target) { + TarArchiveEntry link = new TarArchiveEntry(original.getName(), TarConstants.LF_LINK); + link.setLinkName(target); + link.setMode(original.getMode()); + link.setUserId(original.getLongUserId()); + link.setGroupId(original.getLongGroupId()); + link.setUserName(original.getUserName()); + link.setGroupName(original.getGroupName()); + link.setModTime(original.getModTime()); + return link; + } + + /** Includes output metadata because one inode cannot preserve conflicting attributes. */ + private record HardLinkKey( + Object identity, int mode, long uid, long gid, String user, String group, long modified, long size) {} + /** * Valid Modes for Compression attribute to Tar Task */ @@ -442,6 +549,8 @@ public boolean isSupportingForced() { @Override protected void cleanUp() throws IOException { + hardLinkTargets.clear(); + writtenNames.clear(); super.cleanUp(); if (this.tOut != null) { this.tOut.close(); diff --git a/src/main/java/org/codehaus/plexus/archiver/tar/TarEntryIndex.java b/src/main/java/org/codehaus/plexus/archiver/tar/TarEntryIndex.java new file mode 100644 index 000000000..ba6876b5c --- /dev/null +++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarEntryIndex.java @@ -0,0 +1,158 @@ +/* + * 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.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarConstants; + +/** Resolves TAR member occurrences independently of output names and stream position. */ +final class TarEntryIndex { + private final List entries = new ArrayList<>(); + private final Map positions = new IdentityHashMap<>(); + private final Map> names = new HashMap<>(); + private final Map resolved = new HashMap<>(); + + /** Records one header as a cursor encounters it, retaining occurrence identity on replay. */ + TarArchiveEntry record(int position, TarArchiveEntry entry) throws IOException { + if (position < entries.size()) { + TarArchiveEntry known = entries.get(position); + if (!known.getName().equals(entry.getName()) + || known.getSize() != entry.getSize() + || known.getLinkFlag() != entry.getLinkFlag() + || !known.getLinkName().equals(entry.getLinkName())) { + throw new IOException("TAR changed while reading " + entry.getName()); + } + return known; + } + entries.add(entry); + positions.put(entry, position); + names.computeIfAbsent(normalizedName(entry.getName()), ignored -> new ArrayList<>()) + .add(position); + return entry; + } + + /** Finds an observed occurrence, or the first matching name for caller-created headers. */ + Integer find(TarArchiveEntry entry) { + Integer position = positions.get(entry); + if (position != null) { + return position; + } + List matches = names.get(normalizedName(entry.getName())); + return matches == null ? null : matches.get(0); + } + + /** Returns an observed header by its position in the source archive. */ + TarArchiveEntry entry(int position) { + return entries.get(position); + } + + /** Returns an observed member's occurrence number without reading the archive. */ + int position(TarArchiveEntry entry) throws IOException { + Integer position = find(entry); + if (position == null) { + throw new IOException("Unknown TAR entry: " + entry.getName()); + } + return position; + } + + /** Binds a hard link to the nearest earlier target, never to a later or self occurrence. */ + TarArchiveEntry linkTarget(TarArchiveEntry link) throws IOException { + validateLinkName(link.getName()); + validateLinkName(link.getLinkName()); + String targetName = normalizedName(link.getLinkName()); + if (targetName.equals(normalizedName(link.getName()))) { + throw new IOException("Self-referencing TAR hard link: " + link.getName()); + } + List matches = names.get(targetName); + if (matches != null) { + int found = Collections.binarySearch(matches, position(link)); + int before = found >= 0 ? found - 1 : -found - 2; + if (before >= 0) { + return entries.get(matches.get(before)); + } + } + throw new IOException("TAR hard-link target must precede " + link.getName() + ": " + link.getLinkName()); + } + + /** Resolves chains iteratively, memoizing the payload to keep long chains inexpensive. */ + TarArchiveEntry resolve(TarArchiveEntry entry) throws IOException { + int current = position(entry); + Set visited = new HashSet<>(); + // Bind backward references to the version visible at that point in the archive. + while (entries.get(current).isLink()) { + if (resolved.containsKey(current)) { + current = resolved.get(current); + break; + } + if (!visited.add(current)) { + throw new IOException("Cyclic TAR hard link: " + entry.getName()); + } + // Each step moves backward, so invalid forward references cannot be repaired by replay. + current = position(linkTarget(entries.get(current))); + } + if (!visited.isEmpty() && !isRegular(entries.get(current))) { + throw new IOException("TAR hard link does not reference a regular file: " + entry.getName()); + } + if (!visited.isEmpty()) { + validateLinkName(entries.get(current).getName()); + } + for (Integer link : visited) { + resolved.put(link, current); + } + return entries.get(current); + } + + /** Accepts data-bearing file types, excluding devices and symbolic links. */ + static boolean isRegular(TarArchiveEntry entry) { + return !entry.isDirectory() + && (entry.getLinkFlag() == TarConstants.LF_NORMAL + || entry.getLinkFlag() == TarConstants.LF_OLDNORM + || entry.isSparse()); + } + + /** Rejects filesystem-like escapes before interpreting a hard-link relationship. */ + private static void validateLinkName(String name) throws IOException { + if (name.isEmpty() || name.startsWith("/") || name.contains("\\") || name.contains(":")) { + throw new IOException("Unsafe TAR hard-link name: " + name); + } + for (String component : name.split("/")) { + if (component.equals("..")) { + throw new IOException("Unsafe TAR hard-link name: " + name); + } + } + } + + /** Treats redundant slashes and dot components as the same archive-root-relative name. */ + static String normalizedName(String name) { + List 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 e3d9e6fef..54cc4c765 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 000000000..0e25a661d --- /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 dc6617d42..785d6e951 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 de73c1064..5a3bc50e6 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,20 @@ 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.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 +43,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 +65,33 @@ public TarUnArchiver(File sourceFile) { */ private UntarCompressionMethod compression = UntarCompressionMethod.NONE; + private boolean failOnSymlinkTraversal; + + /** + * 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,36 +127,206 @@ 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); + try (InputStream contents = archive.getInputStream(entry)) { extractFile( sourceFile, destDirectory, - tis, - te.getName(), - te.getModTime(), - te.isDirectory(), - te.getMode() != 0 ? te.getMode() : null, - symlinkDestination, - fileMappers); + contents, + name, + entry.getModTime(), + entry.isDirectory(), + entry.getMode() != 0 ? entry.getMode() : null, + entry.isSymbolicLink() ? entry.getLinkName() : null, + null); } } - 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); + } + } + + /** + * 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 -> { + String name = entry.getName(); + 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); + } + /** * This method wraps the input stream with the * corresponding decompression method diff --git a/src/site/markdown/hard-links.md b/src/site/markdown/hard-links.md new file mode 100644 index 000000000..fe0397fd1 --- /dev/null +++ b/src/site/markdown/hard-links.md @@ -0,0 +1,146 @@ +# TAR hard links + +`TarArchiver` can preserve hard links when explicitly enabled: + +```java +TarArchiver archiver = new TarArchiver(); +archiver.setPreserveHardLinks(true); +archiver.addFileSet(DefaultFileSet.fileSet(inputDirectory)); +archiver.setDestFile(outputArchive); +archiver.createArchive(); +``` + +The default is `false`. Eligible entries share one data-bearing TAR member; +subsequent aliases contain a hard-link header referring to its final archive name. +The option also applies to the compressed TAR archivers. + +Preservation requires a resource implementing Plexus IO's optional +`HardLinkIdentitySupplier`. Local regular files provide an identity when they have +unmodified filesystem contents and the filesystem exposes a file key. Arbitrary +content suppliers, stream transformations, unavailable identities, and conflicting +output metadata result in full regular entries. Transparent name mapping preserves +identity; it changes the archive target name. Symbolic links retain their existing +symbolic-link representation. Truncating long names disables hard-link creation +because a truncated target name may be ambiguous. + +Subclasses of `PlexusIoFileResource` and `TarResource` must explicitly supply their +own hard-link identity guarantee. Inheriting a backing file or archive occurrence +does not establish that an overridden content stream contains the same bytes. + +Like GNU tar and bsdtar, the writer continues preserving hard links when archive +paths traverse symbolic links. Hard-link headers name paths, so extraction can +link to contents replaced by an intervening write through a directory symlink. +For example, writing `real/file`, `redirect -> real`, a replacement +`redirect/file`, then `alias -> real/file` makes default extraction give `alias` +the replacement contents. Disabling preservation instead stores each resource's +own bytes. Unrelated subsequent hard links remain eligible for preservation. + +## Streaming extraction + +Extraction reads the TAR in one forward pass, writing each selected member as it +arrives. There is no preliminary header scan or payload staging. Hard links must +refer to earlier regular members or valid backward-link chains. Selected forward +references, missing source targets, self-links, non-regular targets, and escaping +paths are rejected without scanning ahead or retrying later. + +Source archive names are tracked separately from mapped destination paths. A link +uses its target's current mapped regular file, as GNU tar and bsdtar do. If the +target was excluded or retained by overwrite policy, that existing destination file +supplies the inode and contents. If it is absent, extraction fails; it does not +reread the archive to recover excluded payloads or create excluded target names. + +Duplicate source names bind to the nearest preceding occurrence. Outputs are +applied immediately in archive order. When mappers send different source names to +one destination, later links use that destination's current contents. Link headers +do not overwrite shared inode timestamps or permissions. A same-path link is +rejected, following bsdtar where its behavior differs from GNU tar. + +Hard-link creation errors are reported without silently copying a payload for each +alias. Replacement links are created before replacing an existing output name. +The protected `AbstractUnArchiver.extractFile` signature remains available for +ordinary entries. An error can leave earlier members extracted, as in other +streaming extractors; extraction is not transactional. + +### Optional symlink-traversal rejection + +By default, extraction permits directory symlinks that lead to locations inside +the destination directory, as GNU tar does. To reject traversal through such +links, configure the TAR extractor before calling `extract()`: + +```java +TarUnArchiver extractor = new TarUnArchiver(inputArchive); +extractor.setDestDirectory(outputDirectory); +extractor.setFailOnSymlinkTraversal(true); +extractor.extract(); +``` + +`failOnSymlinkTraversal` defaults to `false` and is inherited by the compressed +TAR extractors. This is an extractor setting; Assembly's writer-side +`archiverConfig` does not configure it. The common `UnArchiver` and provider +configuration interfaces do not expose this TAR-specific option. + +When enabled, selected, mapped output paths and hard-link target paths cannot +traverse intermediate symbolic links. Checks include pre-existing symlinks and +links created by earlier entries, and occur before overwrite decisions or +filesystem changes. Excluded entries are skipped, but a selected hard link still +checks its excluded target's mapped destination. An offending entry throws +`ArchiverException` immediately with the member and symlink paths; earlier outputs +remain in place. This follows bsdtar's rejection of intermediate symlinks, without +its delayed error reporting or its other pathname policies. + +The configured destination directory is trusted even if it is itself reached +through a symlink. The directory is resolved before checking its children, including +when its configured path contains a symlink followed by `..`, as with the utilities' +`-C` directory. Harmless `.` components in the configured root or its absolute +mapped spelling do not change which directory is trusted. Parent components in +member paths remain subject to the intermediate-symlink checks. +Ordinary symlink entries remain allowed; existing rules govern +replacement of final path components. Both settings retain the checks preventing +extraction outside the destination directory. The policy checks encountered +filesystem paths without an archive prescan, replay, or payload staging. + +## Archived file sets and content access + +`TarFile` enumerates lazily and records header occurrences as they are encountered. +Reading the current ordinary member consumes that same stream. Metadata access +for backward links uses the recorded headers without reading their payloads. + +An explicit request for earlier or already-consumed contents may open a separate +replay cursor, including decompression for compressed archives. It does not advance +the entry enumeration. Requested hard-link payloads are cached once per data-bearing +occurrence so subsequent aliases do not each rescan the source. Content-reading +selectors use this same on-demand behavior; ordinary extraction without such +requests needs no replay or payload cache. + +`TarResource` exposes a valid backward alias's logical size, bytes, and data-bearing +metadata. This permits selecting an alias alone from an archived file set, repacking +TAR members, or converting them to ZIP. Unlike filesystem extraction, these explicit +content requests obtain bytes from the archive. Low-level TAR link headers still +have size zero. Concurrent content streams and simultaneous enumerations on one +`TarFile` are not supported. + +## Temporary storage + +Requested hard-link contents are cached once per data-bearing occurrence, +regardless of alias count. Temporary cached payloads are released on `close()`; +callers must close readers and resource collections after use. +For `addArchivedFileSet()` and `addResources()`, the archiver owns the registered +collections and closes them, including their underlying archive readers, when +`createArchive()` finishes or fails. Iterators and wrapped collections are released +even when another resource reports a close failure. + +## Maven Assembly configuration + +The writer option can be passed through Assembly's existing archiver configuration: + +```xml + + true + +``` + +This development change depends on the companion Plexus IO `3.7.1-SNAPSHOT` +identity API. When testing an Assembly version that directly depends on an older +Plexus IO, override both `plexus-archiver` and `plexus-io` in the plugin's dependencies. +Use released versions containing both changes once available; the companion +snapshot must be built locally until then. diff --git a/src/site/site.xml b/src/site/site.xml index 63f169c0a..4a4e06172 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -4,6 +4,7 @@

+ diff --git a/src/test/java/org/codehaus/plexus/archiver/AbstractArchiverTest.java b/src/test/java/org/codehaus/plexus/archiver/AbstractArchiverTest.java index 8d3f43b54..fef96ea5f 100644 --- a/src/test/java/org/codehaus/plexus/archiver/AbstractArchiverTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/AbstractArchiverTest.java @@ -1,12 +1,20 @@ package org.codehaus.plexus.archiver; +import java.io.Closeable; import java.io.File; import java.io.IOException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicInteger; +import org.codehaus.plexus.archiver.tar.PlexusIoTarFileResourceCollection; +import org.codehaus.plexus.components.io.resources.PlexusIoResource; +import org.codehaus.plexus.components.io.resources.proxy.PlexusIoProxyResourceCollection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class AbstractArchiverTest { @@ -90,4 +98,65 @@ void overridesCanBeReset() { void setDestFileInTheWorkingDir() { archiver.setDestFile(new File("archive")); } + + /** Failed iteration or closure must not prevent wrapped collections from releasing their owned resources. */ + @Test + void cleanupReleasesIteratorsAndNestedCollectionsAfterFailures() throws Exception { + AtomicInteger iteratorCloses = new AtomicInteger(); + AtomicInteger collectionCloses = new AtomicInteger(); + class FailingIterator implements Iterator, Closeable { + /** Fails before exhaustion to exercise ownership of partially consumed iterators. */ + @Override + public boolean hasNext() { + throw new IllegalStateException("iteration failed"); + } + + /** No resource can be returned after the simulated iteration failure. */ + @Override + public PlexusIoResource next() { + throw new NoSuchElementException(); + } + + /** Fails during close so collection cleanup must still run. */ + @Override + public void close() throws IOException { + iteratorCloses.incrementAndGet(); + throw new IOException("iterator close failed"); + } + } + PlexusIoTarFileResourceCollection first = new PlexusIoTarFileResourceCollection() { + /** Supplies a closeable iterator whose lifetime starts before the first read. */ + @Override + public Iterator getResources() { + return new FailingIterator(); + } + + /** A collection close failure must not skip the next registered collection. */ + @Override + public void close() throws IOException { + collectionCloses.incrementAndGet(); + throw new IOException("collection close failed"); + } + }; + PlexusIoTarFileResourceCollection second = new PlexusIoTarFileResourceCollection() { + /** Records cleanup even though iteration never reached this collection. */ + @Override + public void close() { + collectionCloses.incrementAndGet(); + } + }; + archiver.addResources(new PlexusIoProxyResourceCollection(new PlexusIoProxyResourceCollection(first))); + archiver.addResources(second); + assertThrows(IllegalStateException.class, () -> archiver.getResources().hasNext()); + IOException failure = assertThrows(IOException.class, archiver::cleanUp); + assertEquals("iterator close failed", failure.getMessage()); + assertEquals(1, failure.getSuppressed().length); + assertEquals("collection close failed", failure.getSuppressed()[0].getMessage()); + assertEquals(1, iteratorCloses.get()); + assertEquals(2, collectionCloses.get()); + // Cleanup consumes its ownership records even when closing fails, allowing subsequent archive operations. + archiver.cleanUp(); + assertEquals(1, iteratorCloses.get()); + assertEquals(2, collectionCloses.get()); + } } diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarArchivedFileSetCleanupTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarArchivedFileSetCleanupTest.java new file mode 100644 index 000000000..1dd08557a --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarArchivedFileSetCleanupTest.java @@ -0,0 +1,115 @@ +/* + * 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.File; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.codehaus.plexus.archiver.Archiver; +import org.codehaus.plexus.archiver.TestSupport; +import org.codehaus.plexus.archiver.util.DefaultArchivedFileSet; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class TarArchivedFileSetCleanupTest extends TestSupport { + @TempDir + Path temp; + + /** A dedicated JVM isolates the real temporary cache without changing global settings in the test suite. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void conversionsReleasePayloadCaches(boolean failure) throws Exception { + Path source = temp.resolve("source.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "payload", null); + TarHardLinkTest.entry(out, "alias", "", "data"); + if (failure) { + TarHardLinkTest.entry(out, "invalid", "", "absent"); + } + } + Path cache = Files.createDirectory(temp.resolve("cache")); + Path log = temp.resolve("conversion.log"); + String java = Path.of(System.getProperty("java.home"), "bin", File.separatorChar == '\\' ? "java.exe" : "java") + .toString(); + Process process = new ProcessBuilder( + java, + "-Djava.io.tmpdir=" + cache, + "-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + TarArchivedFileSetCleanupTest.class.getName(), + source.toString(), + Boolean.toString(failure)) + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "conversion process timed out"); + assertEquals(0, process.exitValue(), Files.readString(log)); + } finally { + process.destroyForcibly(); + } + assertNoPayloadCaches(cache); + } + + /** Exercises the public archived-file-set API repeatedly, including failure after a cached alias was read. */ + public static void main(String[] args) throws Exception { + Path source = Path.of(args[0]); + boolean failure = Boolean.parseBoolean(args[1]); + TarArchivedFileSetCleanupTest test = new TarArchivedFileSetCleanupTest(); + test.setUp(); + try { + Archiver archiver = test.lookup(Archiver.class, "zip"); + for (int i = 0; i < 2; i++) { + DefaultArchivedFileSet set = new DefaultArchivedFileSet(source.toFile()); + set.setIncludes(new String[] {"alias", "invalid"}); + archiver.addArchivedFileSet(set); + Path output = source.resolveSibling("converted-" + i + ".zip"); + archiver.setDestFile(output.toFile()); + if (failure) { + assertThrows(UncheckedIOException.class, archiver::createArchive); + } else { + archiver.createArchive(); + try (var zip = new java.util.zip.ZipFile(output.toFile()); + var contents = zip.getInputStream(zip.getEntry("alias"))) { + assertEquals("payload", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + } + // Callers cannot close the internally created collection; createArchive must release its cache. + assertNoPayloadCaches(Path.of(System.getProperty("java.io.tmpdir"))); + } + } finally { + test.tearDown(); + } + } + + /** Checks ownership of TAR caches independently of other libraries' temporary files. */ + private static void assertNoPayloadCaches(Path directory) throws java.io.IOException { + try (var paths = Files.list(directory)) { + assertEquals( + 0, + paths.filter(path -> path.getFileName().toString().startsWith("plexus-tar-")) + .count()); + } + } +} diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkArchiverTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkArchiverTest.java new file mode 100644 index 000000000..8cf6a6b34 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkArchiverTest.java @@ -0,0 +1,496 @@ +/* + * 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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.codehaus.plexus.archiver.Archiver; +import org.codehaus.plexus.archiver.ArchiverException; +import org.codehaus.plexus.archiver.TestSupport; +import org.codehaus.plexus.archiver.util.DefaultArchivedFileSet; +import org.codehaus.plexus.archiver.util.DefaultFileSet; +import org.codehaus.plexus.components.io.filemappers.FileMapper; +import org.codehaus.plexus.components.io.functions.HardLinkIdentitySupplier; +import org.codehaus.plexus.components.io.resources.PlexusIoResource; +import org.codehaus.plexus.components.io.resources.ResourceFactory; +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 TarHardLinkArchiverTest extends TestSupport { + @TempDir + Path temp; + + /** Creates real aliases, checking filesystem capability rather than assuming it from the OS. */ + Path sources() throws Exception { + Path source = Files.createDirectory(temp.resolve("source")); + Path a = Files.writeString(source.resolve("a"), "content"); + try { + Files.createLink(source.resolve("b"), a); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Hard links unavailable: " + e); + } + assumeTrue( + ((HardLinkIdentitySupplier) ResourceFactory.createResource(a.toFile())).getHardLinkIdentity() != null, + "Filesystem does not expose file keys"); + return source; + } + + /** Writes a fresh destination while retaining the caller's configuration. */ + Path write(TarArchiver archiver) throws Exception { + Path output = Files.createTempFile(temp, "written", ".tar"); + archiver.setDestFile(output.toFile()); + archiver.createArchive(); + return output; + } + + /** Inspects headers and payloads with Commons Compress, independently of our extraction logic. */ + List
headers(Path archive) throws IOException { + List
headers = new ArrayList<>(); + try (TarArchiveInputStream in = new TarArchiveInputStream(Files.newInputStream(archive))) { + TarArchiveEntry entry; + while ((entry = in.getNextEntry()) != null) { + if (!entry.isDirectory()) { + headers.add(new Header(entry, new String(in.readAllBytes(), StandardCharsets.UTF_8))); + } + } + } + return headers; + } + + /** Retains decoded header metadata and bytes for assertions. */ + private record Header(TarArchiveEntry entry, String contents) {} + + /** Opt-in creation writes one full payload and uses final mapped names in later links. */ + @Test + void createsLinksAfterMappingAndResetsBetweenArchives() throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + DefaultFileSet set = new DefaultFileSet(); + set.setDirectory(source.toFile()); + set.setPrefix("prefix/"); + set.setFileMappers(new FileMapper[] {name -> "mapped-" + name}); + for (int i = 0; i < 2; i++) { + archiver.addFileSet(set); + List
entries = headers(write(archiver)); + assertFalse(entries.get(0).entry.isLink()); + assertEquals("content", entries.get(0).contents); + assertTrue(entries.get(1).entry.isLink()); + assertEquals(entries.get(0).entry.getName(), entries.get(1).entry.getLinkName()); + assertEquals(0, entries.get(1).entry.getSize()); + } + } + + /** Disabled preservation must short-circuit before consulting optional resource identity. */ + @Test + void disabledDoesNotReadIdentity() throws Exception { + Path file = Files.writeString(temp.resolve("file"), "content"); + PlexusIoResource original = ResourceFactory.createResource(file.toFile()); + PlexusIoResource guarded = ProxyFactory.createProxy(original, (HardLinkIdentitySupplier) () -> { + throw new AssertionError("Identity queried with preservation disabled"); + }); + TarArchiver archiver = new TarArchiver(); + assertFalse(archiver.isPreserveHardLinks()); + archiver.addResource(guarded, "a", 0644); + archiver.addResource(guarded, "b", 0644); + List
entries = headers(write(archiver)); + assertFalse(entries.get(1).entry.isLink()); + assertEquals("content", entries.get(1).contents); + } + + /** Name-dependent transformations must retain each resource's distinct output bytes. */ + @Test + void transformationsInvalidateIdentity() throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + DefaultFileSet set = new DefaultFileSet(); + set.setDirectory(source.toFile()); + set.setStreamTransformer((resource, contents) -> + new ByteArrayInputStream((resource.getName() + "-transformed").getBytes(StandardCharsets.UTF_8))); + archiver.addFileSet(set); + List
entries = headers(write(archiver)); + assertEquals("a-transformed", entries.get(0).contents); + assertEquals("b-transformed", entries.get(1).contents); + assertFalse(entries.get(1).entry.isLink()); + } + + /** Replacing TAR resource contents must preserve the replacement bytes with either writer setting. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void tarResourceSubclassesKeepReplacementContents(boolean preserve) throws Exception { + Path source = temp.resolve("subclass.tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "payload", null); + } + try (TarFile reader = new TarFile(source.toFile())) { + TarArchiveEntry entry = (TarArchiveEntry) reader.getEntries().nextElement(); + TarResource original = new TarResource(reader, entry); + TarResource changed = new TarResource(reader, entry) { + /** Keeps size and metadata equal so only the content guarantee can distinguish the resources. */ + @Override + public InputStream getContents() { + return new ByteArrayInputStream("changed".getBytes(StandardCharsets.UTF_8)); + } + }; + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(preserve); + archiver.addResource(original, "original", 0644); + archiver.addResource(changed, "changed", 0644); + List
entries = headers(write(archiver)); + assertEquals("payload", entries.get(0).contents); + assertFalse(entries.get(1).entry.isLink()); + assertEquals("changed", entries.get(1).contents); + } + } + + /** A transparent subclass can explicitly guarantee that its inherited contents share the source payload. */ + @Test + void tarResourceSubclassCanSupplyIdentity() throws Exception { + Path source = temp.resolve("transparent.tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "payload", null); + } + try (TarFile reader = new TarFile(source.toFile())) { + TarArchiveEntry entry = (TarArchiveEntry) reader.getEntries().nextElement(); + TarResource original = new TarResource(reader, entry); + TarResource transparent = new TarResource(reader, entry) { + /** Delegates the guarantee to the unchanged source resource. */ + @Override + public Object getHardLinkIdentity() throws IOException { + return original.getHardLinkIdentity(); + } + }; + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addResource(original, "original", 0644); + archiver.addResource(transparent, "alias", 0644); + List
entries = headers(write(archiver)); + assertEquals("payload", entries.get(0).contents); + assertTrue(entries.get(1).entry.isLink()); + assertEquals("original", entries.get(1).entry.getLinkName()); + } + } + + /** One inode cannot retain conflicting requested permission modes. */ + @Test + void differentMetadataKeepsFullEntries() throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addResource(ResourceFactory.createResource(source.resolve("a").toFile()), "a", 0600); + archiver.addResource(ResourceFactory.createResource(source.resolve("b").toFile()), "b", 0644); + List
entries = headers(write(archiver)); + assertFalse(entries.get(1).entry.isLink()); + assertEquals("content", entries.get(1).contents); + } + + /** Equivalent destination paths must invalidate a replaced target before a later alias is written. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource( + strings = {"same", "./same", "dir/../same", "./dir//../same", "dir/nested/../../same", "dir/.././same"}) + void duplicateNamesInvalidateTargets(String replacementName) throws Exception { + Path source = sources(); + Path other = Files.writeString(temp.resolve("other"), "other"); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.setDuplicateBehavior(Archiver.DUPLICATES_ADD); + archiver.addResource(ResourceFactory.createResource(source.resolve("a").toFile()), "same", 0644); + archiver.addResource(ResourceFactory.createResource(other.toFile()), replacementName, 0644); + archiver.addResource(ResourceFactory.createResource(source.resolve("b").toFile()), "last", 0644); + Path archive = write(archiver); + List
entries = headers(archive); + assertEquals(3, entries.size()); + assertFalse(entries.get(2).entry.isLink()); + assertEquals("content", entries.get(2).contents); + // The contained replacement path overwrites "same", but must not change the alias's original bytes. + Path output = Files.createDirectory(temp.resolve("extracted")); + TarUnArchiver extractor = new TarUnArchiver(archive.toFile()); + extractor.setDestDirectory(output.toFile()); + extractor.extract(); + assertEquals("other", Files.readString(output.resolve("same"))); + assertEquals("content", Files.readString(output.resolve("last"))); + } + + /** Like GNU tar, symlink traversal leaves later aliases bound to their target's current contents. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "false, redirect/file", "true, redirect/file", + "false, ./redirect//file", "true, ./redirect//file", + "false, redirect/../file", "true, redirect/../file" + }) + void symlinkPathsKeepHardLinkHeaders(boolean preserve, String replacementName) throws Exception { + Path source = sources(); + requireSymbolicLinks(source); + Path other = Files.writeString(temp.resolve("other"), "changed"); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(preserve); + archiver.addFile(source.resolve("a").toFile(), "real/file", 0644); + archiver.addFile(other.toFile(), "real/sub/marker", 0644); + archiver.addSymlink("redirect", replacementName.contains("..") ? "real/sub" : "real"); + archiver.addFile(other.toFile(), replacementName, 0644); + archiver.addFile(source.resolve("b").toFile(), "alias", 0644); + // An independent group after the collision must still benefit from hard-link preservation. + Path unrelated = Files.writeString(temp.resolve("unrelated-source"), "independent"); + Path unrelatedAlias = Files.createLink(temp.resolve("unrelated-alias"), unrelated); + archiver.addFile(unrelated.toFile(), "unrelated-first", 0644); + archiver.addFile(unrelatedAlias.toFile(), "unrelated-second", 0644); + Path archive = write(archiver); + List
entries = headers(archive); + Header alias = entries.get(entries.size() - 3); + assertEquals(preserve, alias.entry.isLink()); + assertEquals(preserve ? "real/file" : "", alias.entry.getLinkName()); + assertEquals(preserve ? "" : "content", alias.contents); + assertFalse(entries.get(entries.size() - 2).entry.isLink()); + assertEquals(preserve, entries.get(entries.size() - 1).entry.isLink()); + assertEquals( + preserve ? "unrelated-first" : "", + entries.get(entries.size() - 1).entry.getLinkName()); + Path output = Files.createDirectory(temp.resolve("extracted")); + TarUnArchiver extractor = new TarUnArchiver(archive.toFile()); + extractor.setDestDirectory(output.toFile()); + extractor.extract(); + assertEquals("changed", Files.readString(output.resolve("real/file"))); + assertEquals(preserve ? "changed" : "content", Files.readString(output.resolve("alias"))); + assertEquals(preserve, Files.isSameFile(output.resolve("real/file"), output.resolve("alias"))); + assertEquals(preserve, Files.isSameFile(output.resolve("unrelated-first"), output.resolve("unrelated-second"))); + + // Reusing the writer must not retain names from the completed archive. + archiver.addFile(source.resolve("a").toFile(), "first", 0644); + archiver.addFile(source.resolve("b").toFile(), "second", 0644); + List
next = headers(write(archiver)); + assertEquals(preserve, next.get(1).entry.isLink()); + } + + /** Nested directory symlinks also retain pathname-based hard links, as with GNU tar. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void symlinksThroughSymlinksKeepHardLinkHeaders(boolean preserve) throws Exception { + Path source = sources(); + requireSymbolicLinks(source); + Path other = Files.writeString(temp.resolve("other"), "changed"); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(preserve); + archiver.addFile(other.toFile(), "real/marker", 0644); + archiver.addSymlink("redirect", "real"); + archiver.addSymlink("redirect/nested", "../real"); + archiver.addFile(source.resolve("a").toFile(), "real/file", 0644); + archiver.addFile(other.toFile(), "real/nested/file", 0644); + archiver.addFile(source.resolve("b").toFile(), "alias", 0644); + Path archive = write(archiver); + List
entries = headers(archive); + assertEquals(preserve, entries.get(entries.size() - 1).entry.isLink()); + assertEquals( + preserve ? "real/file" : "", + entries.get(entries.size() - 1).entry.getLinkName()); + assertEquals(preserve ? "" : "content", entries.get(entries.size() - 1).contents); + Path output = Files.createDirectory(temp.resolve("extracted")); + TarUnArchiver extractor = new TarUnArchiver(archive.toFile()); + extractor.setDestDirectory(output.toFile()); + extractor.extract(); + assertEquals("changed", Files.readString(output.resolve("real/file"))); + assertEquals(preserve ? "changed" : "content", Files.readString(output.resolve("alias"))); + assertEquals(preserve, Files.isSameFile(output.resolve("real/file"), output.resolve("alias"))); + } + + /** A symlink on an unrelated path must not disable preservation for ordinary members. */ + @Test + void unrelatedSymlinksAllowPreservation() throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addFile(source.resolve("a").toFile(), "first", 0644); + archiver.addSymlink("unrelated", "elsewhere"); + archiver.addFile(source.resolve("b").toFile(), "second", 0644); + List
entries = headers(write(archiver)); + assertEquals("content", entries.get(0).contents); + assertTrue(entries.get(1).entry.isSymbolicLink()); + assertTrue(entries.get(2).entry.isLink()); + assertEquals("first", entries.get(2).entry.getLinkName()); + } + + /** Checks symbolic-link capability on the test filesystem before exercising extraction through a directory link. */ + private void requireSymbolicLinks(Path directory) throws IOException { + try { + Files.createSymbolicLink(directory.resolve("symlink-probe"), Path.of(".")); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + } + + /** A failure after writing one file cannot leak its target identity into the next archive. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void failedArchiveDoesNotLeakTargets(boolean symlinkTraversal) throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addResource(ResourceFactory.createResource(source.resolve("a").toFile()), "first", 0644); + if (symlinkTraversal) { + archiver.addSymlink("redirect", "."); + archiver.addFile(source.resolve("a").toFile(), "redirect/through", 0644); + } + Path missing = Files.writeString(temp.resolve("missing"), "missing"); + archiver.addResource(ResourceFactory.createResource(missing.toFile()), "missing", 0644); + Files.delete(missing); + // The missing file can fail during identity lookup or payload copying; both must reset writer state. + Exception failure = assertThrows(Exception.class, () -> write(archiver)); + assertTrue(failure instanceof ArchiverException || failure instanceof IOException); + archiver.addResource(ResourceFactory.createResource(source.resolve("b").toFile()), "next", 0644); + archiver.addResource(ResourceFactory.createResource(source.resolve("a").toFile()), "next-alias", 0644); + List
entries = headers(write(archiver)); + assertFalse(entries.get(0).entry.isLink()); + assertEquals("content", entries.get(0).contents); + assertTrue(entries.get(1).entry.isLink()); + assertEquals("next", entries.get(1).entry.getLinkName()); + } + + /** Excluding the first alias must cause the remaining selected name to carry the payload. */ + @Test + void excludedFirstAliasIsNotALinkTarget() throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + DefaultFileSet set = new DefaultFileSet(); + set.setDirectory(source.toFile()); + set.setExcludes(new String[] {"a"}); + archiver.addFileSet(set); + List
entries = headers(write(archiver)); + assertEquals(1, entries.size()); + assertFalse(entries.get(0).entry.isLink()); + assertEquals("content", entries.get(0).contents); + } + + /** PAX and GNU long-link extensions must name the same full member written earlier. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource( + value = TarLongFileMode.class, + names = {"posix", "gnu"}) + void supportsLongLinkNames(TarLongFileMode mode) throws Exception { + Path source = sources(); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.setLongfile(mode); + String name = "long/" + "x".repeat(150); + archiver.addResource(ResourceFactory.createResource(source.resolve("a").toFile()), name, 0644); + archiver.addResource(ResourceFactory.createResource(source.resolve("b").toFile()), "short", 0644); + List
entries = headers(write(archiver)); + assertTrue(entries.get(1).entry.isLink()); + assertEquals(name, entries.get(1).entry.getLinkName()); + } + + /** Archive-resource wrappers preserve identities through mapping, including backward aliases. */ + @Test + void repacksSelectedAliasesThroughArchivedFileSet() throws Exception { + Path source = Files.createTempFile(temp, "source", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "content", null); + TarHardLinkTest.entry(out, "a", "", "data"); + TarHardLinkTest.entry(out, "b", "", "data"); + } + TarArchiver archiver = (TarArchiver) lookup(Archiver.class, "tar"); + archiver.setPreserveHardLinks(true); + DefaultArchivedFileSet set = new DefaultArchivedFileSet(source.toFile()); + set.setExcludes(new String[] {"data"}); + set.setFileMappers(new FileMapper[] {name -> "mapped/" + name}); + archiver.addArchivedFileSet(set); + List
entries = headers(write(archiver)); + assertEquals("mapped/a", entries.get(0).entry.getName()); + assertEquals("content", entries.get(0).contents); + assertTrue(entries.get(1).entry.isLink()); + assertEquals("mapped/a", entries.get(1).entry.getLinkName()); + } + + /** Formats without native links receive each alias's logical bytes and size. */ + @Test + void convertsSelectedAliasToZip() throws Exception { + Path source = Files.createTempFile(temp, "source", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "content", null); + TarHardLinkTest.entry(out, "alias", "", "data"); + } + Archiver archiver = lookup(Archiver.class, "zip"); + DefaultArchivedFileSet set = new DefaultArchivedFileSet(source.toFile()); + set.setIncludes(new String[] {"alias"}); + archiver.addArchivedFileSet(set); + Path output = temp.resolve("converted.zip"); + archiver.setDestFile(output.toFile()); + archiver.createArchive(); + try (var zip = new java.util.zip.ZipFile(output.toFile())) { + assertEquals(1, zip.size()); + assertEquals(7, zip.getEntry("alias").getSize()); + assertEquals( + "content", + new String(zip.getInputStream(zip.getEntry("alias")).readAllBytes(), StandardCharsets.UTF_8)); + } + } + /** Unknown identities and replacement content suppliers remain ordinary full entries. */ + @Test + void unknownIdentityAndCustomContentsAreNotLinked() throws Exception { + Path file = Files.writeString(temp.resolve("file"), "disk"); + PlexusIoResource resource = ResourceFactory.createResource(file.toFile()); + PlexusIoResource unknown = ProxyFactory.createProxy(resource, (HardLinkIdentitySupplier) () -> null); + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addResource(unknown, "a", 0644); + archiver.addResource(unknown, "b", 0644); + archiver.addResource( + ResourceFactory.createResource( + file.toFile(), + "c", + () -> new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)), + (org.codehaus.plexus.components.io.functions.InputStreamTransformer) null), + "c", + 0644); + List
entries = headers(write(archiver)); + assertTrue(entries.stream().noneMatch(header -> header.entry.isLink())); + assertEquals("disk", entries.get(1).contents); + assertEquals("data", entries.get(2).contents); + } + + /** Symbolic links retain their own header type instead of inheriting the target's file key. */ + @Test + void symbolicLinksAreNotHardLinks() throws Exception { + Path source = sources(); + try { + Files.createSymbolicLink(source.resolve("symlink"), Path.of("a")); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + TarArchiver archiver = new TarArchiver(); + archiver.setPreserveHardLinks(true); + archiver.addFileSet(DefaultFileSet.fileSet(source.toFile())); + List
entries = headers(write(archiver)); + assertEquals( + 1, + entries.stream().filter(header -> header.entry.isSymbolicLink()).count()); + assertEquals(1, entries.stream().filter(header -> header.entry.isLink()).count()); + } +} diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java new file mode 100644 index 000000000..a96875ac2 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java @@ -0,0 +1,373 @@ +/* + * 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.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; + +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.tar.TarConstants; +import org.codehaus.plexus.components.io.filemappers.FileMapper; +import org.codehaus.plexus.components.io.fileselectors.FileSelector; +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 TarHardLinkTest { + @TempDir + Path temp; + + /** Verifies that the test filesystem supports links without excluding an entire operating system. */ + void requireHardLinks() throws IOException { + Path file = Files.createTempFile(temp, "capability", ""); + try { + Files.createLink(temp.resolve(file.getFileName() + ".link"), file); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Hard links unavailable: " + e); + } + } + + /** Writes a data member or a zero-payload hard-link header for independently constructed fixtures. */ + static void entry(TarArchiveOutputStream out, String name, String contents, String target) throws IOException { + TarArchiveEntry entry = + new TarArchiveEntry(name, target == null ? TarConstants.LF_NORMAL : TarConstants.LF_LINK); + byte[] bytes = contents.getBytes(StandardCharsets.UTF_8); + entry.setModTime(1234567000000L); + entry.setMode(0644); + if (target == null) { + entry.setSize(bytes.length); + } else { + entry.setLinkName(target); + } + out.putArchiveEntry(entry); + if (target == null) { + out.write(bytes); + } + out.closeArchiveEntry(); + } + + /** Creates a target and two aliases, optionally putting the aliases before their data. */ + Path archive(boolean forward) throws IOException { + Path archive = Files.createTempFile(temp, "links", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(archive))) { + if (!forward) { + entry(out, "file", "archive content", null); + } + entry(out, "link", "", "file"); + entry(out, "another", "", "link"); + if (forward) { + entry(out, "file", "archive content", null); + } + } + return archive; + } + + /** Configures a standalone extractor without requiring container lookup. */ + TarUnArchiver unarchiver(Path archive, Path output) { + TarUnArchiver unarchiver = new TarUnArchiver(archive.toFile()); + unarchiver.setDestDirectory(output.toFile()); + return unarchiver; + } + + /** Covers the ordering and mapping concerns from PR 286, including a chain of links. */ + @Test + void extractsBackwardLinksThroughMappers() throws Exception { + requireHardLinks(); + Path output = Files.createDirectory(temp.resolve("mapped")); + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setFileMappers(new FileMapper[] {name -> "renamed/" + name}); + unarchiver.extract(); + assertEquals("archive content", Files.readString(output.resolve("renamed/link"))); + assertTrue(Files.isSameFile(output.resolve("renamed/file"), output.resolve("renamed/link"))); + assertTrue(Files.isSameFile(output.resolve("renamed/file"), output.resolve("renamed/another"))); + } + + /** An excluded target uses the existing mapped destination, as command-line tar does. */ + @Test + void selectedAliasesUseExistingTarget() throws Exception { + requireHardLinks(); + Path output = Files.createDirectory(temp.resolve("selected")); + Files.writeString(output.resolve("file"), "unrelated existing content"); + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setFileSelectors(new FileSelector[] {file -> !file.getName().equals("file")}); + unarchiver.extract(); + assertEquals("unrelated existing content", Files.readString(output.resolve("link"))); + assertEquals("unrelated existing content", Files.readString(output.resolve("file"))); + assertTrue(Files.isSameFile(output.resolve("file"), output.resolve("link"))); + assertTrue(Files.isSameFile(output.resolve("link"), output.resolve("another"))); + } + + /** The archive-resource view must expose logical bytes without skipping the next member. */ + @Test + void resourceContentsResolveLinksWithoutChangingEnumeration() throws Exception { + TarFile file = new TarFile(archive(false).toFile()); + try { + Enumeration entries = file.getEntries(); + assertTrue(entries.hasMoreElements()); + assertEquals("file", entries.nextElement().getName()); + TarResource link = new TarResource(file, (TarArchiveEntry) entries.nextElement()); + assertEquals(15, link.getSize()); + try (var contents = link.getContents()) { + assertEquals("archive content", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + assertTrue(entries.hasMoreElements()); + assertEquals("another", entries.nextElement().getName()); + assertFalse(entries.hasMoreElements()); + } finally { + file.close(); + } + } + /** A missing excluded target fails without seeking back or creating excluded names. */ + @Test + void rejectsAliasWhenExcludedTargetIsAbsent() throws Exception { + Path output = Files.createDirectory(temp.resolve("single")); + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setFileSelectors(new FileSelector[] {file -> file.getName().equals("link")}); + assertThrows(org.codehaus.plexus.archiver.ArchiverException.class, unarchiver::extract); + try (var files = Files.list(output)) { + assertEquals(0, files.count()); + } + } + + /** Replacement must unlink an existing inode instead of modifying its other aliases. */ + @Test + void overwriteDoesNotModifyExistingAliases() throws Exception { + requireHardLinks(); + Path output = Files.createDirectory(temp.resolve("overwrite")); + Files.writeString(output.resolve("link"), "old data"); + Files.createLink(temp.resolve("old-alias"), output.resolve("link")); + unarchiver(archive(false), output).extract(); + assertEquals("archive content", Files.readString(output.resolve("link"))); + assertEquals("old data", Files.readString(temp.resolve("old-alias"))); + assertTrue(Files.isSameFile(output.resolve("link"), output.resolve("file"))); + } + + /** Overwrite policy retains the current target and links aliases to that same inode. */ + @Test + void overwritePolicyLinksToRetainedTarget() throws Exception { + requireHardLinks(); + Path output = Files.createDirectory(temp.resolve("retained")); + Files.writeString(output.resolve("file"), "newer existing data"); + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setOverwrite(false); + unarchiver.extract(); + assertEquals("newer existing data", Files.readString(output.resolve("file"))); + assertEquals("newer existing data", Files.readString(output.resolve("link"))); + assertTrue(Files.isSameFile(output.resolve("link"), output.resolve("another"))); + assertTrue(Files.isSameFile(output.resolve("link"), output.resolve("file"))); + } + + /** Unsupported linking reports a failure, preserves an existing output, and cleans staging. */ + @Test + void unsupportedLinksDoNotSilentlyCopy() throws Exception { + Path output = Files.createDirectory(temp.resolve("unsupported")); + Files.writeString(output.resolve("link"), "existing"); + TarUnArchiver unarchiver = new TarUnArchiver(archive(false).toFile()) { + /** Simulates a filesystem without link support on every test platform. */ + @Override + protected void createHardLink(Path link, Path existing) { + throw new UnsupportedOperationException("test filesystem"); + } + }; + unarchiver.setDestDirectory(output.toFile()); + assertThrows(org.codehaus.plexus.archiver.ArchiverException.class, unarchiver::extract); + assertEquals("existing", Files.readString(output.resolve("link"))); + assertEquals("archive content", Files.readString(output.resolve("file"))); + try (var files = Files.list(output)) { + assertEquals(2, files.count()); + } + } + + /** Missing targets, cycles and traversal must fail instead of creating empty or external files. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource( + strings = {"missing", "cycle", "../outside", "/outside", "dir", "sym"}) + void rejectsInvalidTargets(String target) throws Exception { + Path source = Files.createTempFile(temp, "invalid", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + if (target.equals("cycle")) { + entry(out, "cycle", "", "link"); + } else if (target.equals("dir") || target.equals("sym")) { + TarArchiveEntry special = new TarArchiveEntry( + target, target.equals("dir") ? TarConstants.LF_DIR : TarConstants.LF_SYMLINK); + if (target.equals("sym")) { + special.setLinkName("missing"); + } + out.putArchiveEntry(special); + out.closeArchiveEntry(); + } + entry(out, "link", "", target); + } + Path output = Files.createDirectory(temp.resolve("invalid-output")); + TarUnArchiver unarchiver = unarchiver(source, output); + assertThrows(org.codehaus.plexus.archiver.ArchiverException.class, unarchiver::extract); + assertFalse(Files.exists(output.resolve("link"))); + } + + /** A symlinked destination parent cannot redirect staged payloads outside the output tree. */ + @Test + void rejectsSymlinkEscape() throws Exception { + Path output = Files.createDirectory(temp.resolve("escape-output")); + Path outside = Files.createDirectory(temp.resolve("outside")); + try { + Files.createSymbolicLink(output.resolve("redirect"), outside); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setFileMappers(new FileMapper[] {name -> "redirect/" + name}); + assertThrows(org.codehaus.plexus.archiver.ArchiverException.class, unarchiver::extract); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } + + /** A duplicate data member does not change an alias bound to its earlier occurrence. */ + @Test + void preservesDuplicateOccurrencesAndArchiveOrder() throws Exception { + requireHardLinks(); + Path source = Files.createTempFile(temp, "duplicates", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + entry(out, "file", "old", null); + entry(out, "old-link", "", "file"); + entry(out, "file", "new", null); + entry(out, "new-link", "", "file"); + } + Path output = Files.createDirectory(temp.resolve("duplicates-output")); + unarchiver(source, output).extract(); + assertEquals("old", Files.readString(output.resolve("old-link"))); + assertEquals("new", Files.readString(output.resolve("new-link"))); + assertTrue(Files.isSameFile(output.resolve("file"), output.resolve("new-link"))); + assertFalse(Files.isSameFile(output.resolve("old-link"), output.resolve("new-link"))); + } + + /** Thousands of backward aliases link in one pass without staging their payload. */ + @Test + void longChainsUseOnePayloadAndDataMetadata() throws Exception { + requireHardLinks(); + Path source = Files.createTempFile(temp, "many", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + entry(out, "link0", "data", null); + for (int i = 1; i <= 2000; i++) { + entry(out, "link" + i, "", "link" + (i - 1)); + } + } + Path output = Files.createDirectory(temp.resolve("many-output")); + TarUnArchiver unarchiver = unarchiver(source, output); + unarchiver.extract(); + assertEquals("data", Files.readString(output.resolve("link0"))); + assertTrue(Files.isSameFile(output.resolve("link0"), output.resolve("link2000"))); + } + + /** Content-reading selectors use the configured decoder without disrupting streaming extraction. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(TarUnArchiver.UntarCompressionMethod.class) + void compressedLinksAndContentSelectors(TarUnArchiver.UntarCompressionMethod method) throws Exception { + requireHardLinks(); + Path source = archive(false); + if (method != TarUnArchiver.UntarCompressionMethod.NONE) { + String algorithm = + switch (method) { + case GZIP -> "gz"; + case BZIP2 -> "bzip2"; + case SNAPPY -> "snappy-framed"; + case XZ -> "xz"; + case ZSTD -> "zstd"; + default -> throw new IllegalArgumentException(); + }; + Path compressed = Files.createTempFile(temp, "compressed", ".tar"); + try (var out = new org.apache.commons.compress.compressors.CompressorStreamFactory() + .createCompressorOutputStream(algorithm, Files.newOutputStream(compressed))) { + Files.copy(source, out); + } + source = compressed; + } + Path output = Files.createDirectory(temp.resolve("compressed-output")); + TarUnArchiver unarchiver = unarchiver(source, output); + unarchiver.setCompression(method); + unarchiver.setFileSelectors(new FileSelector[] { + file -> { + if (!file.getName().equals("link")) { + return file.getName().equals("file"); + } + try (var input = file.getContents()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8).equals("archive content"); + } + } + }); + unarchiver.extract(); + assertEquals("archive content", Files.readString(output.resolve("link"))); + } + /** Applying a link header's timestamp or permissions would also modify the payload inode. */ + @Test + void appliesOnlyDataBearingMetadata() throws Exception { + requireHardLinks(); + Path source = Files.createTempFile(temp, "metadata", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + entry(out, "file", "content", null); + TarArchiveEntry link = new TarArchiveEntry("link", TarConstants.LF_LINK); + link.setLinkName("file"); + link.setMode(0600); + link.setModTime(987654300000L); + out.putArchiveEntry(link); + out.closeArchiveEntry(); + } + Path output = Files.createDirectory(temp.resolve("metadata-output")); + unarchiver(source, output).extract(); + assertEquals( + 1234567000000L, + Files.getLastModifiedTime(output.resolve("link")).toMillis()); + assertTrue(Files.isSameFile(output.resolve("file"), output.resolve("link"))); + if (Files.getFileStore(output).supportsFileAttributeView("posix")) { + assertEquals( + java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--"), + Files.getPosixFilePermissions(output.resolve("link"))); + } + } + + /** Mapper collisions link to the current destination contents, matching GNU tar and bsdtar. */ + @Test + void mappingCollisionsKeepArchiveOrder() throws Exception { + requireHardLinks(); + Path source = Files.createTempFile(temp, "collision", ".tar"); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + entry(out, "file", "linked", null); + entry(out, "other", "replacement", null); + entry(out, "alias", "", "file"); + } + Path output = Files.createDirectory(temp.resolve("collision-output")); + TarUnArchiver unarchiver = unarchiver(source, output); + java.util.concurrent.atomic.AtomicInteger mappings = new java.util.concurrent.atomic.AtomicInteger(); + unarchiver.setFileMappers(new FileMapper[] { + name -> { + mappings.incrementAndGet(); + return name.equals("alias") ? "alias" : "same"; + } + }); + unarchiver.extract(); + assertEquals(3, mappings.get()); + assertEquals("replacement", Files.readString(output.resolve("same"))); + assertEquals("replacement", Files.readString(output.resolve("alias"))); + assertTrue(Files.isSameFile(output.resolve("same"), output.resolve("alias"))); + } +} diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java new file mode 100644 index 000000000..8e1d373a5 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java @@ -0,0 +1,356 @@ +/* + * 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.File; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.codehaus.plexus.archiver.ArchiverException; +import org.codehaus.plexus.components.io.filemappers.FileMapper; +import org.codehaus.plexus.components.io.fileselectors.FileSelector; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class TarStreamingTest { + @TempDir + Path temp; + + /** Exercises the streaming contract both with and without links through every decoder. */ + static Stream extractionCases() { + return Arrays.stream(TarUnArchiver.UntarCompressionMethod.values()) + .flatMap(method -> Stream.of(false, true).flatMap(links -> Stream.of(false, true) + .map(rejectTraversal -> Arguments.of(method, links, rejectTraversal)))); + } + + /** A large later member detects an up-front scan even when buffered input reads ahead. */ + @ParameterizedTest + @MethodSource("extractionCases") + void extractsBeforeReadingLaterPayload( + TarUnArchiver.UntarCompressionMethod method, boolean links, boolean rejectTraversal) throws Exception { + Path source = temp.resolve("source.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "early", "first", null); + TarHardLinkTest.entry(out, "bulk", "x".repeat(128 * 1024), null); + if (links) { + TarHardLinkTest.entry(out, "alias", "", "early"); + } + } + long sourceSize = Files.size(source); + Path encoded = compress(source, method); + Path output = Files.createDirectory(temp.resolve("output")); + AtomicInteger opens = new AtomicInteger(); + AtomicLong bytes = new AtomicLong(); + TarUnArchiver extractor = new TarUnArchiver(encoded.toFile()) { + /** Observes decompressed reads while retaining the production compression configuration. */ + @Override + protected TarFile newTarFile(File file) { + TarFile decoder = super.newTarFile(file); + return new TarFile(file) { + /** Counts source opens and verifies output exists before later data is consumed. */ + @Override + protected InputStream getInputStream(File source) throws IOException { + opens.incrementAndGet(); + return new FilterInputStream(decoder.getInputStream(source)) { + /** Counts single-byte reads as well as the usual block reads. */ + @Override + public int read() throws IOException { + int value = in.read(); + observe(value < 0 ? 0 : 1); + return value; + } + + /** Measures decompressed bytes, so compression ratio cannot hide a pre-scan. */ + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int count = in.read(buffer, offset, length); + observe(Math.max(count, 0)); + return count; + } + + /** Header-only scanning must not escape detection through skip operations. */ + @Override + public long skip(long count) throws IOException { + long skipped = in.skip(count); + observe(skipped); + return skipped; + } + + /** Allows buffer read-ahead while rejecting a full preliminary traversal. */ + private void observe(long count) throws IOException { + if (bytes.addAndGet(count) > 64 * 1024) { + assertEquals("first", Files.readString(output.resolve("early"))); + } + } + }; + } + }; + } + }; + extractor.setCompression(method); + extractor.setFailOnSymlinkTraversal(rejectTraversal); + extractor.setDestDirectory(output.toFile()); + extractor.extract(); + assertEquals(1, opens.get(), "ordinary extraction must never open a replay cursor"); + assertTrue(bytes.get() <= sourceSize, "the decoded archive must not be traversed twice"); + assertEquals(128 * 1024, Files.size(output.resolve("bulk"))); + if (links) { + assertTrue(Files.isSameFile(output.resolve("early"), output.resolve("alias"))); + } + try (var files = Files.list(output)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".plexus-"))); + } + } + + /** Compresses the same independently written TAR fixture using the requested format. */ + private Path compress(Path source, TarUnArchiver.UntarCompressionMethod method) throws Exception { + if (method == TarUnArchiver.UntarCompressionMethod.NONE) { + return source; + } + String algorithm = + switch (method) { + case GZIP -> "gz"; + case BZIP2 -> "bzip2"; + case SNAPPY -> "snappy-framed"; + case XZ -> "xz"; + case ZSTD -> "zstd"; + default -> throw new IllegalArgumentException(); + }; + Path encoded = temp.resolve("compressed.tar"); + try (var out = + new CompressorStreamFactory().createCompressorOutputStream(algorithm, Files.newOutputStream(encoded))) { + Files.copy(source, out); + } + return encoded; + } + + /** Forward references fail immediately, even if an unrelated destination target already exists. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rejectsForwardReferencesWithoutRetry(boolean existing) throws Exception { + Path source = temp.resolve("forward.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "early", "first", null); + TarHardLinkTest.entry(out, "alias", "", "later"); + TarHardLinkTest.entry(out, "later", "last", null); + } + Path output = Files.createDirectory(temp.resolve("output")); + if (existing) { + Files.writeString(output.resolve("later"), "existing"); + } + TarUnArchiver extractor = new TarUnArchiver(source.toFile()); + extractor.setDestDirectory(output.toFile()); + assertThrows(ArchiverException.class, extractor::extract); + assertEquals("first", Files.readString(output.resolve("early"))); + assertFalse(Files.exists(output.resolve("alias"))); + if (existing) { + assertEquals("existing", Files.readString(output.resolve("later"))); + } else { + assertFalse(Files.exists(output.resolve("later"))); + } + // Finishing enumeration must not turn an invalid earlier link into a valid backward reference. + try (TarFile reader = new TarFile(source.toFile())) { + var entries = reader.getEntries(); + entries.nextElement(); + TarArchiveEntry link = (TarArchiveEntry) entries.nextElement(); + entries.nextElement(); + assertFalse(entries.hasMoreElements()); + assertThrows(IOException.class, () -> reader.getInputStream(link)); + assertThrows(UncheckedIOException.class, () -> new TarResource(reader, link).getSize()); + } + } + + /** Repeated hasMoreElements calls and replay must not advance the enumeration unexpectedly. */ + @Test + void enumeratesLazilyAndCachesOnlyRequestedLinks() throws Exception { + Path source = temp.resolve("backward.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "file", "content", null); + TarHardLinkTest.entry(out, "alias", "", "file"); + TarHardLinkTest.entry(out, "another", "", "alias"); + } + AtomicInteger opens = new AtomicInteger(); + try (TarFile reader = new TarFile(source.toFile()) { + /** Tracks replay separately from the initial enumeration stream. */ + @Override + protected InputStream getInputStream(File file) throws IOException { + opens.incrementAndGet(); + return super.getInputStream(file); + } + }) { + var entries = reader.getEntries(); + assertEquals(1, opens.get()); + assertTrue(entries.hasMoreElements()); + assertTrue(entries.hasMoreElements()); + TarArchiveEntry first = (TarArchiveEntry) entries.nextElement(); + assertEquals("file", first.getName()); + assertEquals(1, opens.get()); + try (var contents = reader.getInputStream(first)) { + assertEquals("content", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + TarArchiveEntry alias = (TarArchiveEntry) entries.nextElement(); + assertEquals(7, new TarResource(reader, alias).getSize()); + assertEquals(1, opens.get(), "metadata must not replay payloads"); + try (var contents = reader.getInputStream(alias)) { + assertEquals("content", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + assertEquals(2, opens.get()); + TarArchiveEntry another = (TarArchiveEntry) entries.nextElement(); + assertEquals("another", another.getName()); + try (var contents = reader.getInputStream(another)) { + assertEquals("content", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + assertEquals(2, opens.get(), "aliases must reuse their cached payload"); + assertFalse(entries.hasMoreElements()); + assertThrows(java.util.NoSuchElementException.class, entries::nextElement); + } + } + + /** An excluded target is mapped only when referenced, and the mapper result is reused. */ + @Test + void mapsExcludedTargetOnceAndUsesExistingFile() throws Exception { + Path source = temp.resolve("backward.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "file", "archive", null); + TarHardLinkTest.entry(out, "alias", "", "file"); + TarHardLinkTest.entry(out, "another", "", "file"); + } + Path output = Files.createDirectories(temp.resolve("output/mapped")).getParent(); + Files.writeString(output.resolve("mapped/file"), "existing"); + AtomicInteger mappings = new AtomicInteger(); + TarUnArchiver extractor = new TarUnArchiver(source.toFile()); + extractor.setDestDirectory(output.toFile()); + extractor.setFileSelectors(new FileSelector[] {file -> !file.getName().equals("file")}); + extractor.setFileMappers(new FileMapper[] { + name -> { + mappings.incrementAndGet(); + return "mapped/" + name; + } + }); + extractor.extract(); + assertEquals(3, mappings.get()); + assertEquals("existing", Files.readString(output.resolve("mapped/alias"))); + assertTrue(Files.isSameFile(output.resolve("mapped/file"), output.resolve("mapped/another"))); + } + /** A second read of the current ordinary member uses replay without stealing the next header. */ + @Test + void rereadsCurrentMemberAndFindsNamesWithoutSkipping() throws Exception { + Path source = temp.resolve("repeat.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "first", "one", null); + TarHardLinkTest.entry(out, "second", "two", null); + TarHardLinkTest.entry(out, "first", "replacement", null); + } + try (TarFile reader = new TarFile(source.toFile())) { + var entries = reader.getEntries(); + TarArchiveEntry first = (TarArchiveEntry) entries.nextElement(); + try (var contents = reader.getInputStream(first)) { + assertEquals('o', contents.read()); + } + try (var contents = reader.getInputStream(first)) { + assertEquals("one", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + // An explicit name lookup may read ahead on its own cursor, but enumeration stays put. + try (var contents = reader.getInputStream(new TarArchiveEntry("second"))) { + assertEquals("two", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + assertEquals("second", entries.nextElement().getName()); + TarArchiveEntry replacement = (TarArchiveEntry) entries.nextElement(); + try (var contents = reader.getInputStream(replacement)) { + assertEquals("replacement", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + try (var contents = reader.getInputStream(new TarArchiveEntry("first"))) { + assertEquals("one", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + assertFalse(entries.hasMoreElements()); + } + } + + /** Same-name and mapped same-path links must fail without deleting the existing target. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rejectsSelfLinks(boolean mapped) throws Exception { + Path source = temp.resolve("self.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "file", "content", null); + TarHardLinkTest.entry(out, mapped ? "alias" : "file", "", "file"); + } + Path output = Files.createDirectory(temp.resolve("output")); + TarUnArchiver extractor = new TarUnArchiver(source.toFile()); + extractor.setDestDirectory(output.toFile()); + if (mapped) { + extractor.setFileMappers(new FileMapper[] {name -> "file"}); + } + assertThrows(ArchiverException.class, extractor::extract); + assertEquals("content", Files.readString(output.resolve("file"))); + } + + /** Payload ownership cleans successful caches and incomplete copies after I/O failures. */ + @Test + void cleansCachedPayloadsOnCloseAndFailure() throws Exception { + Path source = temp.resolve("cache.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "file", "content", null); + } + Path cached; + try (TarFile reader = new TarFile(source.toFile()); + TarPayloads payloads = new TarPayloads()) { + TarArchiveEntry entry = (TarArchiveEntry) reader.getEntries().nextElement(); + cached = payloads.add(reader, entry); + assertEquals(cached, payloads.add(reader, entry)); + assertEquals("content", Files.readString(cached)); + try (TarFile failing = new TarFile(source.toFile()) { + /** Simulates a stream-close failure after all bytes have been copied. */ + @Override + InputStream rawContents(TarArchiveEntry ignored) { + return new java.io.ByteArrayInputStream(new byte[0]) { + /** Failing closure must not leave an untracked temporary payload. */ + @Override + public void close() throws IOException { + throw new IOException("close failed"); + } + }; + } + }) { + TarArchiveEntry empty = new TarArchiveEntry("empty"); + assertThrows(IOException.class, () -> payloads.add(failing, empty)); + } + try (var files = Files.list(cached.getParent())) { + assertEquals(1, files.count()); + } + } + assertFalse(Files.exists(cached)); + assertFalse(Files.exists(cached.getParent())); + } +} diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java new file mode 100644 index 000000000..a93ead879 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java @@ -0,0 +1,518 @@ +/* + * 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.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.stream.Stream; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.codehaus.plexus.archiver.ArchiverException; +import org.codehaus.plexus.components.io.filemappers.FileMapper; +import org.codehaus.plexus.components.io.fileselectors.FileSelector; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class TarSymlinkTraversalTest { + @TempDir + Path temp; + + /** Describes independently constructed headers; value is either regular contents or a link target. */ + private record Member(String name, char type, String value) {} + + /** Writes a fixture directly through the selected compressor, without using the production writer. */ + private Path archive(TarUnArchiver.UntarCompressionMethod compression, Member... members) throws Exception { + Path archive = Files.createTempFile(temp, "symlinks", ".tar"); + String algorithm = + switch (compression) { + case NONE -> null; + case GZIP -> "gz"; + case BZIP2 -> "bzip2"; + case SNAPPY -> "snappy-framed"; + case XZ -> "xz"; + case ZSTD -> "zstd"; + }; + try (OutputStream file = Files.newOutputStream(archive); + OutputStream encoded = algorithm == null + ? file + : new CompressorStreamFactory().createCompressorOutputStream(algorithm, file); + TarArchiveOutputStream out = new TarArchiveOutputStream(encoded)) { + for (Member member : members) { + TarArchiveEntry entry = new TarArchiveEntry(member.name, (byte) member.type); + entry.setMode(0755); + entry.setModTime(1234567000000L); + byte[] bytes = member.value.getBytes(StandardCharsets.UTF_8); + if (member.type == '0') { + entry.setSize(bytes.length); + } else if (member.type == '1' || member.type == '2') { + entry.setLinkName(member.value); + } + out.putArchiveEntry(entry); + if (member.type == '0') { + out.write(bytes); + } + out.closeArchiveEntry(); + } + } + return archive; + } + + /** Creates a symlink only when the test filesystem permits it. */ + private void symlink(Path link, Path target) throws IOException { + try { + Files.createSymbolicLink(link, target); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + } + + /** Checks actual link capabilities, so unsupported filesystems skip only the affected cases. */ + private void requireLinks() throws IOException { + Path original = Files.writeString(temp.resolve("probe"), "probe"); + symlink(temp.resolve("probe-symlink"), original); + try { + Files.createLink(temp.resolve("probe-hardlink"), original); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Hard links unavailable: " + e); + } + } + + /** Covers the inherited setting on each concrete compressed TAR extractor. */ + private TarUnArchiver extractor(Path archive, Path output, TarUnArchiver.UntarCompressionMethod compression) { + TarUnArchiver extractor = + switch (compression) { + case NONE -> new TarUnArchiver(); + case GZIP -> new TarGZipUnArchiver(); + case BZIP2 -> new TarBZip2UnArchiver(); + case SNAPPY -> new TarSnappyUnArchiver(); + case XZ -> new TarXZUnArchiver(); + case ZSTD -> new TarZstdUnArchiver(); + }; + extractor.setSourceFile(archive.toFile()); + extractor.setDestDirectory(output.toFile()); + return extractor; + } + + /** Exercises both policies through every TAR decoder. */ + static Stream policies() { + return Arrays.stream(TarUnArchiver.UntarCompressionMethod.values()) + .flatMap(compression -> Stream.of(false, true).map(reject -> Arguments.of(compression, reject))); + } + + /** GNU defaults use the current target; rejection stops before the redirected replacement is written. */ + @ParameterizedTest + @MethodSource("policies") + void directorySymlinkCollision(TarUnArchiver.UntarCompressionMethod compression, boolean reject) throws Exception { + requireLinks(); + Path source = archive( + compression, + new Member("real/file", '0', "original"), + new Member("redirect", '2', "real"), + new Member("redirect/file", '0', "changed!"), + new Member("alias", '1', "real/file")); + Path output = Files.createDirectory(temp.resolve("output")); + TarUnArchiver extractor = extractor(source, output, compression); + assertFalse(extractor.isFailOnSymlinkTraversal()); + if (reject) { + extractor.setFailOnSymlinkTraversal(true); + assertTrue(extractor.isFailOnSymlinkTraversal()); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("destination 'redirect/file'")); + assertTrue(error.getMessage().contains("symbolic link '" + output.resolve("redirect") + "'")); + assertEquals("original", Files.readString(output.resolve("real/file"))); + assertFalse(Files.exists(output.resolve("alias"))); + } else { + extractor.extract(); + assertEquals("changed!", Files.readString(output.resolve("alias"))); + assertTrue(Files.isSameFile(output.resolve("alias"), output.resolve("real/file"))); + } + assertTrue(Files.isSymbolicLink(output.resolve("redirect"))); + } + + /** Mapping and normalization must not conceal existing symlink parents, even with missing prefixes. */ + @ParameterizedTest + @ValueSource( + strings = { + "redirect/file", + "./redirect//file", + "redirect/../file", + "redirect/./file", + "missing/../redirect/file", + "real/../redirect/file", + "redirect/new/file" + }) + void checksMappedPathsBeforeNormalization(String mapped) throws Exception { + Path output = Files.createDirectory(temp.resolve("output")); + Files.createDirectory(output.resolve("real")); + symlink(output.resolve("redirect"), Path.of("real")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("before", '0', "before"), + new Member("logical", '0', "replacement")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers(new FileMapper[] {name -> name.equals("logical") ? mapped : name}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("entry 'logical'")); + assertTrue(error.getMessage().contains("destination '" + mapped + "'")); + assertTrue(error.getMessage().contains("symbolic link")); + assertEquals("before", Files.readString(output.resolve("before"))); + try (var children = Files.list(output.resolve("real"))) { + assertEquals(0, children.count()); + } + assertFalse(Files.exists(output.resolve("file"))); + assertFalse(Files.exists(output.resolve("missing"))); + } + + /** Every output kind checks parents before creating directories, files, or additional links. */ + @ParameterizedTest + @ValueSource(strings = {"0", "1", "2", "5"}) + void rejectsAllEntryKindsBeforeChanges(String kind) throws Exception { + requireLinks(); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("real/file", '0', "original"), + new Member("redirect", '2', "real"), + new Member("redirect/new/entry", kind.charAt(0), "real/file")); + Path output = Files.createDirectory(temp.resolve("output")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("destination 'redirect/new/entry")); + assertFalse(Files.exists(output.resolve("real/new"))); + assertEquals("original", Files.readString(output.resolve("real/file"))); + } + + /** Excluded source members still require checking their mapped path when a selected hard link uses it. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void checksMappedHardLinkTarget(boolean reject) throws Exception { + requireLinks(); + Path output = Files.createDirectory(temp.resolve("output")); + Files.createDirectory(output.resolve("real")); + Files.writeString(output.resolve("real/file"), "existing"); + symlink(output.resolve("redirect"), Path.of("real")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("source", '0', "archive"), + new Member("alias", '1', "source")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFileSelectors(new FileSelector[] {file -> !file.getName().equals("source")}); + extractor.setFileMappers(new FileMapper[] {name -> name.equals("source") ? "redirect/file" : name}); + extractor.setFailOnSymlinkTraversal(reject); + if (reject) { + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("entry 'alias'")); + assertTrue(error.getMessage().contains("hard-link target 'redirect/file'")); + assertFalse(Files.exists(output.resolve("alias"))); + } else { + extractor.extract(); + assertEquals("existing", Files.readString(output.resolve("alias"))); + assertTrue(Files.isSameFile(output.resolve("alias"), output.resolve("real/file"))); + } + } + + /** Selection skips unsafe entries, but overwrite policy does not bypass validation of selected paths. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void selectionAndOverwrite(boolean excluded) throws Exception { + Path output = Files.createDirectory(temp.resolve("output")); + Files.createDirectory(output.resolve("real")); + Files.writeString(output.resolve("real/file"), "existing"); + symlink(output.resolve("redirect"), Path.of("real")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("redirect/file", '0', "replacement"), + new Member("after", '0', "after")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setOverwrite(false); + if (excluded) { + extractor.setFileSelectors( + new FileSelector[] {file -> !file.getName().equals("redirect/file")}); + extractor.extract(); + assertEquals("after", Files.readString(output.resolve("after"))); + } else { + assertThrows(ArchiverException.class, extractor::extract); + assertFalse(Files.exists(output.resolve("after"))); + } + assertEquals("existing", Files.readString(output.resolve("real/file"))); + } + + /** The configured root is trusted, while unrelated symbolic-link entries remain allowed. */ + @Test + void acceptsTrustedRootAndUnrelatedSymlinks() throws Exception { + Path realRoot = Files.createDirectory(temp.resolve("real-root")); + Path output = temp.resolve("output"); + symlink(output, realRoot); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("redirect", '2', "elsewhere"), + new Member("./nested/../directory/file", '0', "content")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.extract(); + assertEquals("content", Files.readString(realRoot.resolve("directory/file"))); + assertTrue(Files.isSymbolicLink(realRoot.resolve("redirect"))); + } + + /** Builds a trusted root whose lexical parent differs from its filesystem-resolved parent. */ + private Path rootThroughSymlinkAndParent() throws IOException { + Path actual = Files.createDirectory(temp.resolve("actual")); + Files.createDirectory(actual.resolve("sub")); + Path lexical = Files.createDirectory(temp.resolve("lexical")); + symlink(lexical.resolve("hop"), actual.resolve("sub")); + return lexical.resolve("hop/.."); + } + + /** Both relative and absolute mappings must inspect the actual children of a trusted root alias. */ + @ParameterizedTest + @ValueSource(strings = {"relative", "configured-absolute", "canonical-absolute"}) + void checksChildrenOfRootThroughSymlinkAndParent(String spelling) throws Exception { + Path output = rootThroughSymlinkAndParent(); + Path actual = output.toFile().getCanonicalFile().toPath(); + Files.createDirectory(actual.resolve("real")); + symlink(actual.resolve("redirect"), Path.of("real")); + String mapped = + switch (spelling) { + case "configured-absolute" -> + output.resolve("redirect/file").toString(); + case "canonical-absolute" -> actual.resolve("redirect/file").toString(); + default -> "redirect/file"; + }; + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("logical", '0', "changed")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers(new FileMapper[] {name -> mapped}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("entry 'logical'")); + assertTrue(error.getMessage().contains("symbolic link '" + actual.resolve("redirect") + "'")); + assertFalse(Files.exists(actual.resolve("real/file"))); + } + + /** A selected link checks its current destination and target beneath the actual trusted root. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void checksHardLinksUnderRootThroughSymlinkAndParent(boolean targetTraversal) throws Exception { + requireLinks(); + Path output = rootThroughSymlinkAndParent(); + Path actual = output.toFile().getCanonicalFile().toPath(); + Files.createDirectory(actual.resolve("real")); + Files.writeString(actual.resolve("real/file"), "existing"); + symlink(actual.resolve("redirect"), Path.of("real")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("source", '0', "archive"), + new Member("alias", '1', "source")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileSelectors(new FileSelector[] {file -> !file.getName().equals("source")}); + extractor.setFileMappers(new FileMapper[] { + name -> name.equals("source") + ? (targetTraversal ? "redirect/file" : "real/file") + : (targetTraversal ? "alias" : "redirect/alias") + }); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains(targetTraversal ? "hard-link target" : "destination")); + assertTrue(error.getMessage().contains("symbolic link '" + actual.resolve("redirect") + "'")); + assertFalse(Files.exists(actual.resolve("alias"))); + assertFalse(Files.exists(actual.resolve("real/alias"))); + assertEquals("existing", Files.readString(actual.resolve("real/file"))); + } + + /** Unrelated symlinks in the lexical tree must not reject safe extraction in the actual tree. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void acceptsSafeChildrenOfRootThroughSymlinkAndParent(boolean reject) throws Exception { + requireLinks(); + Path output = rootThroughSymlinkAndParent(); + Path actual = output.toFile().getCanonicalFile().toPath(); + symlink(temp.resolve("lexical/real"), Path.of("elsewhere")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("real/file", '0', "content"), + new Member("alias", '1', "real/file")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(reject); + extractor.extract(); + assertEquals("content", Files.readString(actual.resolve("alias"))); + assertTrue(Files.isSameFile(actual.resolve("real/file"), actual.resolve("alias"))); + assertTrue(Files.isSymbolicLink(temp.resolve("lexical/real"))); + } + + /** Dot components in either root spelling must not prevent safe regular-file and hard-link extraction. */ + @ParameterizedTest + @CsvSource({ + "output, output", + "output, ./output", + "./output, output", + "output/., output", + "./output/., ./output/.", + "output, actual", + "lexical/./hop/.., lexical/hop/../.", + "lexical/hop/../., lexical/./hop/.." + }) + void acceptsHarmlessRootSpellings(String configured, String mapped) throws Exception { + requireLinks(); + Path actual = Files.createDirectory(temp.resolve("actual")); + Files.createDirectory(actual.resolve("sub")); + symlink(temp.resolve("output"), actual); + Files.createDirectory(temp.resolve("lexical")); + symlink(temp.resolve("lexical/hop"), actual.resolve("sub")); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("file", '0', "content"), + new Member("alias", '1', "file")); + TarUnArchiver extractor = + extractor(source, temp.resolve(configured), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> temp.resolve(mapped).resolve(name).toString()}); + extractor.extract(); + assertEquals("content", Files.readString(actual.resolve("file"))); + assertTrue(Files.isSameFile(actual.resolve("file"), actual.resolve("alias"))); + } + + /** Matching a dotted root must leave its suffix intact, including terminal dots and symlinks before parents. */ + @ParameterizedTest + @ValueSource( + strings = { + "./redirect/file", + "redirect/../file", + "redirect/./../file", + "redirect/.", + "missing/../redirect/file" + }) + void dottedRootStillChecksEntrySuffix(String suffix) throws Exception { + Path actual = Files.createDirectory(temp.resolve("actual")); + Files.createDirectory(actual.resolve("real")); + symlink(actual.resolve("redirect"), Path.of("real")); + symlink(temp.resolve("output"), actual); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("logical", '0', "changed")); + TarUnArchiver extractor = + extractor(source, temp.resolve("./output/."), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> temp.resolve("output").resolve(suffix).toString()}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + // Rejection must identify the symlink beneath the trusted root, not the root alias itself. + assertTrue(error.getMessage().contains("symbolic link '" + actual.resolve("redirect") + "'")); + assertFalse(Files.exists(actual.resolve("file"))); + assertFalse(Files.exists(actual.resolve("real/file"))); + } + + /** Both hard-link paths retain intermediate symlink checks after ignoring dots in the trusted prefix. */ + @ParameterizedTest + @CsvSource({"false, redirect/file", "true, redirect/file", "false, redirect/../file", "true, redirect/../file"}) + void dottedRootStillChecksHardLinkPaths(boolean targetTraversal, String suffix) throws Exception { + requireLinks(); + Path actual = Files.createDirectory(temp.resolve("actual")); + Files.createDirectory(actual.resolve("real")); + Files.writeString(actual.resolve("real/file"), "existing"); + symlink(actual.resolve("redirect"), Path.of("real")); + symlink(temp.resolve("output"), actual); + String unsafe = temp.resolve("output").resolve(suffix).toString(); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("source", '0', "archive"), + new Member("alias", '1', "source")); + TarUnArchiver extractor = + extractor(source, temp.resolve("./output/."), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileSelectors(new FileSelector[] {file -> !file.getName().equals("source")}); + extractor.setFileMappers(new FileMapper[] { + name -> name.equals("source") + ? (targetTraversal ? unsafe : "real/file") + : (targetTraversal ? "alias" : unsafe) + }); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains(targetTraversal ? "hard-link target" : "destination")); + assertTrue(error.getMessage().contains("symbolic link '" + actual.resolve("redirect") + "'")); + assertEquals("existing", Files.readString(actual.resolve("real/file"))); + assertFalse(Files.exists(actual.resolve("alias"))); + assertFalse(Files.exists(actual.resolve("file"))); + } + + /** Absolute mappings must be checked before FileUtils canonicalization erases the symlink components. */ + @ParameterizedTest + @ValueSource(strings = {"lexical", "canonical", "alternate", "backslash"}) + void checksAbsoluteMappedPaths(String spelling) throws Exception { + Path realRoot = Files.createDirectory(temp.resolve("real-root")); + Files.createDirectory(realRoot.resolve("real")); + symlink(realRoot.resolve("redirect"), Path.of("real")); + Path output = temp.resolve("output"); + symlink(output, realRoot); + Path alternate = temp.resolve("alternate"); + symlink(alternate, realRoot); + String mapped = + switch (spelling) { + case "canonical" -> realRoot.resolve("redirect/file").toString(); + case "alternate" -> alternate.resolve("redirect/file").toString(); + case "backslash" -> output.resolve("redirect").toString() + "\\file"; + default -> output.resolve("redirect/file").toString(); + }; + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("logical", '0', "content")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers(new FileMapper[] {name -> mapped}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("destination '" + mapped + "'")); + assertTrue(error.getMessage().contains("symbolic link")); + assertFalse(Files.exists(realRoot.resolve("real/file"))); + } + + /** Relative backslashes remain literal on Unix, matching the existing extraction path resolver. */ + @Test + void acceptsLiteralRelativeBackslashes() throws Exception { + assumeTrue(java.io.File.separatorChar == '/', "Backslash is a path separator on this platform"); + Path output = Files.createDirectory(temp.resolve("output")); + symlink(output.resolve("redirect"), Path.of("elsewhere")); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("redirect\\file", '0', "content")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.extract(); + assertEquals("content", Files.readString(output.resolve("redirect\\file"))); + } + + /** Switching the instance policy after a failure takes effect without retaining per-archive path state. */ + @Test + void canDisableRejectionAfterFailure() throws Exception { + Path output = Files.createDirectory(temp.resolve("output")); + Files.createDirectory(output.resolve("real")); + symlink(output.resolve("redirect"), Path.of("real")); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("redirect/file", '0', "content")); + TarUnArchiver extractor = extractor(source, output, TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + assertThrows(ArchiverException.class, extractor::extract); + extractor.setFailOnSymlinkTraversal(false); + extractor.extract(); + assertEquals("content", Files.readString(output.resolve("real/file"))); + } +} From c2169e29dbb1a7290027d8f853faa453266a99fc Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Mon, 14 Sep 2026 20:25:26 +0200 Subject: [PATCH 2/2] Preserve original arguments for TAR extraction hooks --- .../plexus/archiver/tar/TarUnArchiver.java | 72 ++++- src/site/markdown/hard-links.md | 277 +++++++++++------- .../plexus/archiver/tar/TarStreamingTest.java | 116 ++++++++ 3 files changed, 354 insertions(+), 111 deletions(-) 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 5a3bc50e6..57d604131 100644 --- a/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java @@ -28,6 +28,7 @@ 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; @@ -67,6 +68,12 @@ public TarUnArchiver(File sourceFile) { 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. @@ -147,17 +154,22 @@ protected void execute(File sourceFile, File destDirectory, FileMapper[] fileMap } 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, contents, - name, + entry.getName(), entry.getModTime(), entry.isDirectory(), entry.getMode() != 0 ? entry.getMode() : null, entry.isSymbolicLink() ? entry.getLinkName() : null, - null); + fileMappers); + } finally { + // A failed, skipped or nested hook must not leak its mapped destination into another call. + mappedEntry = previous; } } } @@ -167,6 +179,44 @@ protected void execute(File sourceFile, File destDirectory, FileMapper[] fileMap } } + /** + * 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 @@ -185,15 +235,17 @@ protected InputStream getInputStream(File file) throws IOException { /** 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 -> { - String name = entry.getName(); - if (fileMappers != null) { - for (FileMapper mapper : fileMappers) { - name = mapper.getMappedFileName(name); - } + 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; - }); + } + return name; } /** diff --git a/src/site/markdown/hard-links.md b/src/site/markdown/hard-links.md index fe0397fd1..ca2bb0b2d 100644 --- a/src/site/markdown/hard-links.md +++ b/src/site/markdown/hard-links.md @@ -1,6 +1,13 @@ # TAR hard links -`TarArchiver` can preserve hard links when explicitly enabled: +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(); @@ -10,62 +17,99 @@ archiver.setDestFile(outputArchive); archiver.createArchive(); ``` -The default is `false`. Eligible entries share one data-bearing TAR member; -subsequent aliases contain a hard-link header referring to its final archive name. -The option also applies to the compressed TAR archivers. - -Preservation requires a resource implementing Plexus IO's optional -`HardLinkIdentitySupplier`. Local regular files provide an identity when they have -unmodified filesystem contents and the filesystem exposes a file key. Arbitrary -content suppliers, stream transformations, unavailable identities, and conflicting -output metadata result in full regular entries. Transparent name mapping preserves -identity; it changes the archive target name. Symbolic links retain their existing -symbolic-link representation. Truncating long names disables hard-link creation -because a truncated target name may be ambiguous. - -Subclasses of `PlexusIoFileResource` and `TarResource` must explicitly supply their -own hard-link identity guarantee. Inheriting a backing file or archive occurrence -does not establish that an overridden content stream contains the same bytes. - -Like GNU tar and bsdtar, the writer continues preserving hard links when archive -paths traverse symbolic links. Hard-link headers name paths, so extraction can -link to contents replaced by an intervening write through a directory symlink. -For example, writing `real/file`, `redirect -> real`, a replacement -`redirect/file`, then `alias -> real/file` makes default extraction give `alias` -the replacement contents. Disabling preservation instead stores each resource's -own bytes. Unrelated subsequent hard links remain eligible for preservation. +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 -Extraction reads the TAR in one forward pass, writing each selected member as it -arrives. There is no preliminary header scan or payload staging. Hard links must -refer to earlier regular members or valid backward-link chains. Selected forward -references, missing source targets, self-links, non-regular targets, and escaping -paths are rejected without scanning ahead or retrying later. +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. -Source archive names are tracked separately from mapped destination paths. A link -uses its target's current mapped regular file, as GNU tar and bsdtar do. If the -target was excluded or retained by overwrite policy, that existing destination file -supplies the inode and contents. If it is absent, extraction fails; it does not -reread the archive to recover excluded payloads or create excluded target names. +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. -Duplicate source names bind to the nearest preceding occurrence. Outputs are -applied immediately in archive order. When mappers send different source names to -one destination, later links use that destination's current contents. Link headers -do not overwrite shared inode timestamps or permissions. A same-path link is -rejected, following bsdtar where its behavior differs from GNU tar. +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. -Hard-link creation errors are reported without silently copying a payload for each -alias. Replacement links are created before replacing an existing output name. -The protected `AbstractUnArchiver.extractFile` signature remains available for -ordinary entries. An error can leave earlier members extracted, as in other -streaming extractors; extraction is not transactional. +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. -### Optional symlink-traversal rejection +The protected `AbstractUnArchiver.extractFile` method signature stays available +for members that are not hard links. -By default, extraction permits directory symlinks that lead to locations inside -the destination directory, as GNU tar does. To reject traversal through such -links, configure the TAR extractor before calling `extract()`: +### 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); @@ -74,64 +118,93 @@ extractor.setFailOnSymlinkTraversal(true); extractor.extract(); ``` -`failOnSymlinkTraversal` defaults to `false` and is inherited by the compressed -TAR extractors. This is an extractor setting; Assembly's writer-side -`archiverConfig` does not configure it. The common `UnArchiver` and provider -configuration interfaces do not expose this TAR-specific option. - -When enabled, selected, mapped output paths and hard-link target paths cannot -traverse intermediate symbolic links. Checks include pre-existing symlinks and -links created by earlier entries, and occur before overwrite decisions or -filesystem changes. Excluded entries are skipped, but a selected hard link still -checks its excluded target's mapped destination. An offending entry throws -`ArchiverException` immediately with the member and symlink paths; earlier outputs -remain in place. This follows bsdtar's rejection of intermediate symlinks, without -its delayed error reporting or its other pathname policies. - -The configured destination directory is trusted even if it is itself reached -through a symlink. The directory is resolved before checking its children, including -when its configured path contains a symlink followed by `..`, as with the utilities' -`-C` directory. Harmless `.` components in the configured root or its absolute -mapped spelling do not change which directory is trusted. Parent components in -member paths remain subject to the intermediate-symlink checks. -Ordinary symlink entries remain allowed; existing rules govern -replacement of final path components. Both settings retain the checks preventing -extraction outside the destination directory. The policy checks encountered -filesystem paths without an archive prescan, replay, or payload staging. +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` enumerates lazily and records header occurrences as they are encountered. -Reading the current ordinary member consumes that same stream. Metadata access -for backward links uses the recorded headers without reading their payloads. +`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. -An explicit request for earlier or already-consumed contents may open a separate -replay cursor, including decompression for compressed archives. It does not advance -the entry enumeration. Requested hard-link payloads are cached once per data-bearing -occurrence so subsequent aliases do not each rescan the source. Content-reading -selectors use this same on-demand behavior; ordinary extraction without such -requests needs no replay or payload cache. +`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. -`TarResource` exposes a valid backward alias's logical size, bytes, and data-bearing -metadata. This permits selecting an alias alone from an archived file set, repacking -TAR members, or converting them to ZIP. Unlike filesystem extraction, these explicit -content requests obtain bytes from the archive. Low-level TAR link headers still -have size zero. Concurrent content streams and simultaneous enumerations on one -`TarFile` are not supported. +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 -Requested hard-link contents are cached once per data-bearing occurrence, -regardless of alias count. Temporary cached payloads are released on `close()`; -callers must close readers and resource collections after use. -For `addArchivedFileSet()` and `addResources()`, the archiver owns the registered -collections and closes them, including their underlying archive readers, when -`createArchive()` finishes or fails. Iterators and wrapped collections are released -even when another resource reports a close failure. +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 -The writer option can be passed through Assembly's existing archiver configuration: +To enable hard-link preservation in Maven Assembly, use this archiver +configuration: ```xml @@ -139,8 +212,10 @@ The writer option can be passed through Assembly's existing archiver configurati ``` -This development change depends on the companion Plexus IO `3.7.1-SNAPSHOT` -identity API. When testing an Assembly version that directly depends on an older -Plexus IO, override both `plexus-archiver` and `plexus-io` in the plugin's dependencies. -Use released versions containing both changes once available; the companion -snapshot must be built locally until then. +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/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java index 8e1d373a5..c67d36fe7 100644 --- a/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarStreamingTest.java @@ -15,6 +15,7 @@ */ package org.codehaus.plexus.archiver.tar; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FilterInputStream; import java.io.IOException; @@ -23,7 +24,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; @@ -54,6 +58,118 @@ static Stream extractionCases() { .map(rejectTraversal -> Arguments.of(method, links, rejectTraversal)))); } + /** Subclass hooks see original arguments while stateful mapping is shared by checks, output and link targets. */ + @ParameterizedTest + @MethodSource("extractionCases") + void preservesMappedExtractionHookArguments( + TarUnArchiver.UntarCompressionMethod method, boolean links, boolean rejectTraversal) throws Exception { + Path source = temp.resolve("hook.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "data", "payload", null); + TarHardLinkTest.entry(out, "ordinary", "skip this", null); + if (links) { + TarHardLinkTest.entry(out, "alias", "", "data"); + } + } + Path output = Files.createDirectory(temp.resolve("hook-output")); + AtomicInteger mappings = new AtomicInteger(); + FileMapper[] mappers = {name -> "mapped/" + name + "-" + mappings.incrementAndGet(), name -> "prefix/" + name}; + List names = new ArrayList<>(); + List mapperArguments = new ArrayList<>(); + TarUnArchiver extractor = new TarUnArchiver(compress(source, method).toFile()) { + /** Models an existing subclass that selects by source name and delegates with the supplied mappers. */ + @Override + protected void extractFile( + File archive, + File directory, + InputStream contents, + String name, + Date date, + boolean isDirectory, + Integer mode, + String symlink, + FileMapper[] fileMappers) + throws IOException { + names.add(name); + mapperArguments.add(fileMappers); + if (!name.equals("ordinary")) { + super.extractFile( + archive, directory, contents, name, date, isDirectory, mode, symlink, fileMappers); + } + } + }; + extractor.setCompression(method); + extractor.setDestDirectory(output.toFile()); + extractor.setFileMappers(mappers); + extractor.setFailOnSymlinkTraversal(rejectTraversal); + extractor.extract(); + + assertEquals(List.of("data", "ordinary"), names); + mapperArguments.forEach(argument -> assertSame(mappers, argument)); + assertEquals(links ? 3 : 2, mappings.get(), "each selected occurrence must be mapped only once"); + assertEquals("payload", Files.readString(output.resolve("prefix/mapped/data-1"))); + assertFalse(Files.exists(output.resolve("prefix/mapped/ordinary-2"))); + if (links) { + assertEquals("payload", Files.readString(output.resolve("prefix/mapped/alias-3"))); + assertTrue( + Files.isSameFile(output.resolve("prefix/mapped/data-1"), output.resolve("prefix/mapped/alias-3"))); + } + } + + /** A failed subclass hook must release its mapping before a later direct call to the protected method. */ + @Test + void clearsMappedHookStateAfterFailure() throws Exception { + Path source = temp.resolve("failed-hook.tar"); + try (var out = new TarArchiveOutputStream(Files.newOutputStream(source))) { + TarHardLinkTest.entry(out, "ordinary", "payload", null); + } + Path output = Files.createDirectory(temp.resolve("failed-hook-output")); + AtomicInteger mappings = new AtomicInteger(); + FileMapper[] mappers = {name -> name + "-" + mappings.incrementAndGet()}; + class FailingExtractor extends TarUnArchiver { + /** Models a subclass that aborts extraction after the destination was mapped. */ + @Override + protected void extractFile( + File archive, + File directory, + InputStream contents, + String name, + Date date, + boolean isDirectory, + Integer mode, + String symlink, + FileMapper[] fileMappers) + throws IOException { + throw new IOException("hook failure"); + } + + /** Exercises a super call outside the archive loop using the same original name and mapper array. */ + void extractDirectly() throws IOException { + try (InputStream contents = new ByteArrayInputStream("direct".getBytes(StandardCharsets.UTF_8))) { + super.extractFile( + source.toFile(), + output.toFile(), + contents, + "ordinary", + new Date(), + false, + 0644, + null, + mappers); + } + } + } + FailingExtractor extractor = new FailingExtractor(); + extractor.setSourceFile(source.toFile()); + extractor.setDestDirectory(output.toFile()); + extractor.setFileMappers(mappers); + assertThrows(ArchiverException.class, extractor::extract); + extractor.extractDirectly(); + assertEquals(2, mappings.get()); + assertFalse(Files.exists(output.resolve("ordinary-1"))); + assertEquals("direct", Files.readString(output.resolve("ordinary-2"))); + } + /** A large later member detects an up-front scan even when buffered input reads ahead. */ @ParameterizedTest @MethodSource("extractionCases")