From 69e7c1279e53ab8d91fc4b4a0391efa94f2d369a Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sun, 13 Sep 2026 21:57:07 +0200 Subject: [PATCH 1/8] Resolve TAR hard links independently of extraction paths --- .../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 ++++++++++++++++++ 15 files changed, 2913 insertions(+), 153 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/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 dd5b761cb..0daba210c 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 87769aaee369d27467a8ade78ffabfade7eaa167 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Mon, 14 Sep 2026 20:25:26 +0200 Subject: [PATCH 2/8] Preserve original arguments for TAR extraction hooks --- .../plexus/archiver/tar/TarUnArchiver.java | 72 ++++- src/site/markdown/hard-links.md | 276 +++++++++++------- .../plexus/archiver/tar/TarStreamingTest.java | 116 ++++++++ 3 files changed, 353 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..8cd41d2b6 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,9 @@ 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 released in Plexus IO `3.8.0` 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. + +Use a Plexus Archiver version containing this change and Plexus IO `3.8.0` or later. 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") From 5d1f393eec2c0e9a374e7f0e54def4fb61900547 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 12:03:35 +0200 Subject: [PATCH 3/8] Fix TAR extraction through ancestor symlink aliases --- .../plexus/archiver/tar/TarUnArchiver.java | 12 ++ src/site/markdown/hard-links.md | 11 ++ .../archiver/tar/TarSymlinkTraversalTest.java | 143 +++++++++++++++++- 3 files changed, 163 insertions(+), 3 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 57d604131..5521a98c4 100644 --- a/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java @@ -272,9 +272,12 @@ private void checkSymlinkTraversal(File directory, String name, String entryName } } Path component = path.getRoot(); + boolean reachedRoot = canonicalRoot.equals(component); 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(); + // A later '..' must not restore ancestor exemptions for the untrusted suffix. + reachedRoot |= canonicalRoot.equals(component); // Only the actual trusted root and its ancestors are exempt from traversal checks. if (canonicalRoot.startsWith(component)) { continue; @@ -282,6 +285,15 @@ private void checkSymlinkTraversal(File directory, String name, String entryName try { if (Files.readAttributes(component, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS) .isSymbolicLink()) { + if (!reachedRoot) { + Path resolved = component.toFile().getCanonicalFile().toPath(); + // Accept ancestor aliases such as macOS /var, but not a separate alias of the root itself. + // Resolve only this prefix so symlinks and parent components in the suffix remain visible. + if (!resolved.equals(canonicalRoot) && canonicalRoot.startsWith(resolved)) { + component = resolved; + continue; + } + } throw new ArchiverException("Cannot extract TAR entry '" + entryName + "': " + (linkTarget ? "hard-link target" : "destination") + " '" + name + "' traverses symbolic link '" + component + "'"); diff --git a/src/site/markdown/hard-links.md b/src/site/markdown/hard-links.md index 8cd41d2b6..6cd6f9b5c 100644 --- a/src/site/markdown/hard-links.md +++ b/src/site/markdown/hard-links.md @@ -146,6 +146,17 @@ 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. +An absolute mapped path can reach the root through a symbolic link to an +ancestor directory. For example, macOS uses `/var` as an alias for +`/private/var`. The extractor resolves these ancestor aliases before it reaches +the root. A separate symbolic link to the root itself is accepted only in the +configured root spelling. + +After the path reaches the root, the extractor rejects subsequent intermediate +symbolic links. A later `..` component does not restore the ancestor exception. +The checks preserve the remaining path components when they resolve an ancestor +alias. + 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 diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java index a93ead879..dcea414c3 100644 --- a/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java @@ -34,6 +34,7 @@ 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; @@ -145,7 +146,8 @@ void directorySymlinkCollision(TarUnArchiver.UntarCompressionMethod compression, 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") + "'")); + assertTrue(error.getMessage() + .contains("symbolic link '" + output.toRealPath().resolve("redirect") + "'")); assertEquals("original", Files.readString(output.resolve("real/file"))); assertFalse(Files.exists(output.resolve("alias"))); } else { @@ -423,7 +425,8 @@ void dottedRootStillChecksEntrySuffix(String suffix) throws Exception { 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") + "'")); + assertTrue(error.getMessage() + .contains("symbolic link '" + actual.toRealPath().resolve("redirect") + "'")); assertFalse(Files.exists(actual.resolve("file"))); assertFalse(Files.exists(actual.resolve("real/file"))); } @@ -454,12 +457,146 @@ void dottedRootStillChecksHardLinkPaths(boolean targetTraversal, String suffix) }); 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") + "'")); + assertTrue(error.getMessage() + .contains("symbolic link '" + actual.toRealPath().resolve("redirect") + "'")); + assertEquals("existing", Files.readString(actual.resolve("real/file"))); + assertFalse(Files.exists(actual.resolve("alias"))); + assertFalse(Files.exists(actual.resolve("file"))); + } + + /** Models a temporary directory reached through an ancestor symlink, such as /var on macOS. */ + private Path rootUnderSymlinkedParent() throws IOException { + Path parent = Files.createDirectory(temp.resolve("parent")); + Files.createDirectory(parent.resolve("actual")); + symlink(temp.resolve("parent-alias"), parent); + symlink(parent.resolve("output"), Path.of("actual")); + return temp.resolve("parent-alias/actual"); + } + + /** Absolute mappings may name the actual root through an ancestor alias rather than the configured root alias. */ + @ParameterizedTest + @EnumSource(TarUnArchiver.UntarCompressionMethod.class) + void acceptsRootUnderSymlinkedParent(TarUnArchiver.UntarCompressionMethod compression) throws Exception { + requireLinks(); + Path mappedRoot = rootUnderSymlinkedParent(); + Path actual = mappedRoot.toRealPath(); + Path source = archive(compression, new Member("file", '0', "content"), new Member("alias", '1', "file")); + TarUnArchiver extractor = extractor(source, mappedRoot.resolveSibling("output"), compression); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> mappedRoot.resolve(name).toString()}); + extractor.extract(); + assertEquals("content", Files.readString(actual.resolve("file"))); + assertTrue(Files.isSameFile(actual.resolve("file"), actual.resolve("alias"))); + } + + /** Resolving an ancestor alias must not conceal symlinks in the remaining mapped path. */ + @ParameterizedTest + @ValueSource( + strings = { + "./redirect/file", + "redirect/../file", + "redirect/./../file", + "redirect/.", + "missing/../redirect/file" + }) + void checksEntrySuffixUnderSymlinkedParent(String suffix) throws Exception { + Path mappedRoot = rootUnderSymlinkedParent(); + Path actual = mappedRoot.toRealPath(); + Files.createDirectory(actual.resolve("real")); + Files.writeString(actual.resolve("real/file"), "existing"); + Files.writeString(actual.resolve("file"), "existing"); + symlink(actual.resolve("redirect"), Path.of("real")); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("logical", '0', "changed")); + TarUnArchiver extractor = + extractor(source, mappedRoot.resolveSibling("output"), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> mappedRoot.resolve(suffix).toString()}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue( + error.getMessage().contains("symbolic link '" + actual.resolve("redirect") + "'"), error::getMessage); + assertEquals("existing", Files.readString(actual.resolve("file"))); + assertEquals("existing", Files.readString(actual.resolve("real/file"))); + assertTrue(Files.isSymbolicLink(actual.resolve("redirect"))); + assertFalse(Files.exists(actual.resolve("missing"))); + } + + /** Both hard-link paths must retain suffix checks after an ancestor alias is resolved. */ + @ParameterizedTest + @CsvSource({"false, redirect/alias", "true, redirect/file", "false, redirect/../alias", "true, redirect/../file"}) + void checksHardLinkPathsUnderSymlinkedParent(boolean targetTraversal, String suffix) throws Exception { + requireLinks(); + Path mappedRoot = rootUnderSymlinkedParent(); + Path actual = mappedRoot.toRealPath(); + Files.createDirectory(actual.resolve("real")); + Files.writeString(actual.resolve("real/file"), "existing"); + Files.writeString(actual.resolve("file"), "existing"); + symlink(actual.resolve("redirect"), Path.of("real")); + String unsafe = mappedRoot.resolve(suffix).toString(); + Path source = archive( + TarUnArchiver.UntarCompressionMethod.NONE, + new Member("source", '0', "archive"), + new Member("alias", '1', "source")); + TarUnArchiver extractor = + extractor(source, mappedRoot.resolveSibling("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") + "'"), error::getMessage); + assertEquals("existing", Files.readString(actual.resolve("file"))); assertEquals("existing", Files.readString(actual.resolve("real/file"))); assertFalse(Files.exists(actual.resolve("alias"))); + assertFalse(Files.exists(actual.resolve("real/alias"))); + } + + /** An ancestor alias is trusted, but a separate alias pointing directly at the root is not. */ + @Test + void rejectsUnconfiguredRootAlias() throws Exception { + Path mappedRoot = rootUnderSymlinkedParent(); + Path actual = mappedRoot.toRealPath(); + Path alternate = mappedRoot.resolveSibling("alternate"); + symlink(alternate, Path.of("actual")); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("file", '0', "content")); + TarUnArchiver extractor = + extractor(source, mappedRoot.resolveSibling("output"), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> alternate.resolve(name).toString()}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue( + error.getMessage().contains("symbolic link '" + actual.resolveSibling("alternate") + "'"), + error::getMessage); assertFalse(Files.exists(actual.resolve("file"))); } + /** Reaching the root ends ancestor exemptions, even if a later parent component leaves the root again. */ + @ParameterizedTest + @ValueSource(strings = {"back/actual/file", "../back/actual/file"}) + void rejectsAncestorAliasAfterReachingRoot(String suffix) throws Exception { + Path mappedRoot = rootUnderSymlinkedParent(); + Path actual = mappedRoot.toRealPath(); + Files.writeString(actual.resolve("file"), "existing"); + Path link = (suffix.startsWith("../") ? actual.getParent() : actual).resolve("back"); + symlink(link, actual.getParent()); + Path source = archive(TarUnArchiver.UntarCompressionMethod.NONE, new Member("file", '0', "changed")); + TarUnArchiver extractor = + extractor(source, mappedRoot.resolveSibling("output"), TarUnArchiver.UntarCompressionMethod.NONE); + extractor.setFailOnSymlinkTraversal(true); + extractor.setFileMappers( + new FileMapper[] {name -> mappedRoot.resolve(suffix).toString()}); + ArchiverException error = assertThrows(ArchiverException.class, extractor::extract); + assertTrue(error.getMessage().contains("symbolic link '" + link + "'"), error::getMessage); + assertEquals("existing", Files.readString(actual.resolve("file"))); + } + /** Absolute mappings must be checked before FileUtils canonicalization erases the symlink components. */ @ParameterizedTest @ValueSource(strings = {"lexical", "canonical", "alternate", "backslash"}) From 22560cccb1bc806e32fe5e89a97a4dab2b8c7737 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 13:47:53 +0200 Subject: [PATCH 4/8] Resolve extraction paths consistently across platforms Resolve existing path components before checking containment, including missing destinations and symlink/.. roots. Share the resolver between ordinary extraction and TAR hard links, reject final symlinks for ordinary entries, and avoid changing symlink target metadata. Add shared, TAR, and ZIP regressions while preserving traversal policy and extraction hooks. --- .../plexus/archiver/AbstractUnArchiver.java | 174 ++++++++--- .../plexus/archiver/tar/TarUnArchiver.java | 32 +- src/site/markdown/hard-links.md | 15 +- .../plexus/archiver/ExtractionPathTest.java | 295 ++++++++++++++++++ .../plexus/archiver/tar/TarHardLinkTest.java | 36 +++ 5 files changed, 497 insertions(+), 55 deletions(-) create mode 100644 src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java diff --git a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java index 114f45993..c0f7bfc82 100644 --- a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java @@ -20,10 +20,19 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileSystemLoopException; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.NotDirectoryException; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Date; +import java.util.Deque; import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicInteger; @@ -33,7 +42,6 @@ import org.codehaus.plexus.components.io.filemappers.FileMapper; import org.codehaus.plexus.components.io.fileselectors.FileSelector; import org.codehaus.plexus.components.io.resources.PlexusIoResource; -import org.codehaus.plexus.util.FileUtils; import org.codehaus.plexus.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -188,14 +196,19 @@ protected void validate() throws ArchiverException { throw new ArchiverException("You must choose between a destination directory and a destination file."); } - if (destDirectory != null && !destDirectory.isDirectory()) { - destFile = destDirectory; - destDirectory = null; - } + try { + // Use the same link/.. semantics for classification and extraction, retaining the configured spelling. + if (destDirectory != null && !Files.isDirectory(resolveExtractionRoot(destDirectory))) { + destFile = destDirectory; + destDirectory = null; + } - if (destFile != null && destFile.isDirectory()) { - destDirectory = destFile; - destFile = null; + if (destFile != null && Files.isDirectory(resolveExtractionRoot(destFile))) { + destDirectory = destFile; + destFile = null; + } + } catch (IOException e) { + throw new ArchiverException("Cannot resolve extraction destination", e); } } @@ -263,23 +276,10 @@ protected void extractFile( } } - // Hmm. Symlinks re-evaluate back to the original file here. Unsure if this is a good thing... - final File targetFileName = FileUtils.resolveFile(dir, entryName); - - // Make sure that the resolved path of the extracted file doesn't escape the destination directory - // getCanonicalFile().toPath() is used instead of getCanonicalPath() (returns String), - // because "/opt/directory".startsWith("/opt/dir") would return false negative. - Path canonicalDirPath = dir.getCanonicalFile().toPath(); - Path canonicalDestPath = targetFileName.getCanonicalFile().toPath(); - - if (!canonicalDestPath.startsWith(canonicalDirPath)) { - throw new ArchiverException("Entry is outside of the target directory (" + entryName + ")"); - } - - // don't allow override target symlink by standard file - if (StringUtils.isEmpty(symlinkDestination) && Files.isSymbolicLink(canonicalDestPath)) { - throw new ArchiverException("Entry is outside of the target directory (" + entryName + ")"); - } + final boolean symlink = !StringUtils.isEmpty(symlinkDestination); + Path destination = resolveExtractionPath(dir, entryName); + checkFinalSymlink(destination, entryName, symlink); + File targetFileName = destination.toFile(); try { if (!shouldExtractEntry(dir, targetFileName, entryName, entryDate)) { @@ -287,22 +287,37 @@ protected void extractFile( } // create intermediary directories - sometimes zip don't add them - final File dirF = targetFileName.getParentFile(); - if (dirF != null) { - dirF.mkdirs(); + if (destination.getParent() != null) { + Files.createDirectories(destination.getParent()); } + destination = resolveExtractionPath(dir, entryName); + checkFinalSymlink(destination, entryName, symlink); + targetFileName = destination.toFile(); - if (!StringUtils.isEmpty(symlinkDestination)) { + if (symlink) { SymlinkUtils.createSymbolicLink(targetFileName, new File(symlinkDestination)); } else if (isDirectory) { - targetFileName.mkdirs(); + Files.createDirectories(destination); } else { - Files.copy(compressedInputStream, targetFileName.toPath(), REPLACE_EXISTING); + Files.copy(compressedInputStream, destination, REPLACE_EXISTING); } - targetFileName.setLastModified(entryDate.getTime()); + if (symlink) { + // A link can point outside the extraction root. Never change its target's metadata. + if (Files.isSymbolicLink(destination)) { + try { + BasicFileAttributeView attributes = Files.getFileAttributeView( + destination, BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + attributes.setTimes(FileTime.fromMillis(entryDate.getTime()), null, null); + } catch (IOException | UnsupportedOperationException ignored) { + // Link timestamps are best effort, like File.setLastModified for ordinary entries. + } + } + } else { + targetFileName.setLastModified(entryDate.getTime()); + } - if (!isIgnorePermissions() && mode != null && !isDirectory) { + if (!symlink && !isIgnorePermissions() && mode != null && !isDirectory) { ArchiveEntryUtils.chmod(targetFileName, mode); } } catch (final FileNotFoundException ex) { @@ -310,6 +325,95 @@ protected void extractFile( } } + private static void checkFinalSymlink(Path destination, String entryName, boolean symlink) { + if (!symlink && Files.isSymbolicLink(destination)) { + throw new ArchiverException("Entry is outside of the target directory (" + entryName + ")"); + } + } + + /** + * Resolves the trusted root, following each link before processing subsequent parent components. + * @param directory the configured root, whose original spelling remains available to extraction hooks + * @return the physical root, including any not-yet-existing suffix + * @throws IOException if an existing component cannot be resolved + */ + protected final Path resolveExtractionRoot(File directory) throws IOException { + return resolvePath(directory.toPath(), true); + } + + /** + * Resolves an entry's parents and checks containment, leaving a final symlink for entry-type validation. + * @param directory the trusted extraction root + * @param name the mapped destination name + * @return a contained physical path without following its final symbolic link + * @throws IOException if an existing component cannot be resolved + * @throws ArchiverException if the destination is outside the root + */ + protected final Path resolveExtractionPath(File directory, String name) throws IOException { + Path root = resolveExtractionRoot(directory); + // Preserve the previous resolver's portable absolute names and platform-native relative names. + Path portable = Path.of(name.replace('/', File.separatorChar).replace('\\', File.separatorChar)); + Path path = portable.isAbsolute() ? portable : root.resolve(Path.of(name)); + Path destination = resolvePath(path, false); + if (!destination.startsWith(root)) { + throw new ArchiverException("Entry is outside of the target directory (" + name + ")"); + } + return destination; + } + + /** + * Resolves existing components individually: Windows canonicalization of a missing leaf can leave its + * symlinked parents unresolved, and whole-path normalization can erase a link before a later {@code ..}. + */ + private static Path resolvePath(Path path, boolean followLastLink) throws IOException { + Path absolute = path.toAbsolutePath(); + Path current = absolute.getRoot(); + Deque remaining = new ArrayDeque<>(); + absolute.forEach(remaining::addLast); + int links = 0; + while (!remaining.isEmpty()) { + Path name = remaining.removeFirst(); + if (name.toString().isEmpty() || name.toString().equals(".")) { + continue; + } + if (name.toString().equals("..")) { + if (current.getParent() != null) { + current = current.getParent(); + } + continue; + } + Path candidate = current.resolve(name); + BasicFileAttributes attributes; + try { + attributes = Files.readAttributes(candidate, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException ignored) { + current = candidate; + continue; + } + if (attributes.isSymbolicLink()) { + if (remaining.isEmpty() && !followLastLink) { + return candidate; + } + if (++links > 64) { + throw new FileSystemLoopException(path.toString()); + } + // Resolve the target against the physical parent. Do not normalize away link/.. components. + Path target = current.resolve(Files.readSymbolicLink(candidate)).toAbsolutePath(); + for (int i = target.getNameCount() - 1; i >= 0; i--) { + remaining.addFirst(target.getName(i)); + } + current = target.getRoot(); + } else { + if (!remaining.isEmpty() && !attributes.isDirectory()) { + throw new NotDirectoryException(candidate.toString()); + } + // With no unresolved parent links or dots, this also handles Windows casing and short names. + current = candidate.toRealPath(); + } + } + return current; + } + /** * Counter for casing message emitted, visible for testing. */ @@ -341,10 +445,10 @@ protected boolean shouldExtractEntry(File targetDirectory, File targetFileName, boolean entryIsDirectory = entryName.endsWith("/"); // directory entries always end with '/', regardless of the OS. - String canonicalDestPath = targetFileName.getCanonicalPath(); + String canonicalDestPath = resolvePath(targetFileName.toPath(), false).toString(); String suffix = (entryIsDirectory ? "/" : ""); String relativeCanonicalDestPath = - canonicalDestPath.replace(targetDirectory.getCanonicalPath() + File.separatorChar, "") + suffix; + canonicalDestPath.replace(resolveExtractionRoot(targetDirectory) + File.separator, "") + suffix; boolean fileOnDiskIsOlderThanEntry = targetFileName.lastModified() < entryDate.getTime(); boolean differentCasing = !normalizedFileSeparator(entryName).equals(normalizedFileSeparator(relativeCanonicalDestPath)); 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 5521a98c4..e7cb933e1 100644 --- a/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/tar/TarUnArchiver.java @@ -44,7 +44,6 @@ 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; @@ -258,8 +257,8 @@ private void checkSymlinkTraversal(File directory, String name, String entryName 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 canonicalRoot = resolveExtractionRoot(directory); + // Extraction 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)); @@ -286,7 +285,7 @@ private void checkSymlinkTraversal(File directory, String name, String entryName if (Files.readAttributes(component, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS) .isSymbolicLink()) { if (!reachedRoot) { - Path resolved = component.toFile().getCanonicalFile().toPath(); + Path resolved = resolveExtractionRoot(component.toFile()); // Accept ancestor aliases such as macOS /var, but not a separate alias of the root itself. // Resolve only this prefix so symlinks and parent components in the suffix remain visible. if (!resolved.equals(canonicalRoot) && canonicalRoot.startsWith(resolved)) { @@ -333,18 +332,10 @@ private static int trustedRootPrefixLength(Path path, Path root) { return next; } - /** Checks the traversal policy and both lexical and resolved containment before hard-link operations. */ + /** Checks the traversal policy and physical 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; + return resolveExtractionPath(directory, name); } /** Links the current mapped target, following the filesystem semantics of command-line tar. */ @@ -352,8 +343,7 @@ private void extractHardLink(File directory, TarArchiveEntry entry, String name, 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())) { + if (output.equals(target)) { throw new IOException("Self-referencing TAR hard-link output: " + name); } if (Files.isSymbolicLink(output) || Files.isDirectory(output, LinkOption.NOFOLLOW_LINKS)) { @@ -367,8 +357,14 @@ private void extractHardLink(File directory, TarArchiveEntry entry, String name, 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); + output = checkedOutput(directory, name, entry.getName(), false); + target = checkedOutput(directory, targetName, entry.getName(), true); + if (output.equals(target) + || Files.isSymbolicLink(output) + || Files.isDirectory(output, LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("TAR hard-link paths changed during extraction: " + name); + } Path temporary = output.getParent().resolve(".plexus-link-" + UUID.randomUUID()); try { // Build the replacement first so link-creation failure leaves an existing output untouched. diff --git a/src/site/markdown/hard-links.md b/src/site/markdown/hard-links.md index 6cd6f9b5c..53efa7cdf 100644 --- a/src/site/markdown/hard-links.md +++ b/src/site/markdown/hard-links.md @@ -146,6 +146,13 @@ 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. +Path resolution follows each existing symbolic link before processing the next +component, including `..`, on all operating systems. It also resolves existing +parents when the destination file does not exist yet. This avoids differences +between Windows JDK versions when canonicalizing a complete path. Resolution +fails after 64 symbolic-link expansions to bound cyclic or excessively long +paths. + An absolute mapped path can reach the root through a symbolic link to an ancestor directory. For example, macOS uses `/var` as an alias for `/private/var`. The extractor resolves these ancestor aliases before it reaches @@ -163,8 +170,12 @@ 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 +`false`, preserving their target text and creating them only if the destination +does not already exist. It does not change the target's permissions or timestamps. +Regular-file, directory, and hard-link entries reject a symbolic link at their +destination's last component, including a dangling link. Hard-link targets must +also be regular files, rather than symbolic links. Both traversal settings check +physical containment before creating directories or writing files. 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. diff --git a/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java new file mode 100644 index 000000000..2cd31c945 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java @@ -0,0 +1,295 @@ +/* + * 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; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystemLoopException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Arrays; +import java.util.Date; +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.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.codehaus.plexus.archiver.tar.TarUnArchiver; +import org.codehaus.plexus.archiver.zip.ZipUnArchiver; +import org.codehaus.plexus.components.io.filemappers.FileMapper; +import org.junit.jupiter.api.Timeout; +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.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Exercises the shared path policy through both archive readers and the protected extraction hook. */ +class ExtractionPathTest { + enum Format { + SHARED, + TAR, + ZIP + } + + enum Kind { + FILE, + DIRECTORY, + SYMLINK + } + + @TempDir + Path temp; + + private static Stream ordinaryEntries() { + return Arrays.stream(Format.values()) + .flatMap(format -> Stream.of(Kind.FILE, Kind.DIRECTORY).map(kind -> Arguments.of(format, kind))); + } + + private static Stream finalLinks() { + return ordinaryEntries().flatMap(arguments -> Stream.of("inside", "outside", "missing") + .map(target -> Arguments.of(arguments.get()[0], arguments.get()[1], target))); + } + + private void symlink(Path link, Path target) throws IOException { + try { + Files.createSymbolicLink(link, target); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + } + + /** Uses an independent archive writer and a mapper so absolute names reach the extractor unchanged. */ + private void extract(Format format, Path root, String name, Kind kind, String value) throws IOException { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + String member = kind == Kind.DIRECTORY ? "member/" : "member"; + Path archive = Files.createTempFile(temp, "path", ".archive"); + AbstractUnArchiver extractor; + if (format == Format.TAR) { + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(archive))) { + byte type = (byte) (kind == Kind.FILE ? '0' : kind == Kind.DIRECTORY ? '5' : '2'); + TarArchiveEntry entry = new TarArchiveEntry(member, type); + entry.setModTime(1234567000000L); + entry.setMode(0755); + if (kind == Kind.FILE) { + entry.setSize(bytes.length); + } else if (kind == Kind.SYMLINK) { + entry.setLinkName(value); + } + out.putArchiveEntry(entry); + if (kind == Kind.FILE) { + out.write(bytes); + } + out.closeArchiveEntry(); + } + extractor = new TarUnArchiver(); + } else if (format == Format.ZIP) { + try (ZipArchiveOutputStream out = new ZipArchiveOutputStream(Files.newOutputStream(archive))) { + ZipArchiveEntry entry = new ZipArchiveEntry(member); + entry.setUnixMode((kind == Kind.SYMLINK ? 0120000 : kind == Kind.DIRECTORY ? 0040000 : 0100000) | 0755); + entry.setTime(1234567000000L); + out.putArchiveEntry(entry); + if (kind != Kind.DIRECTORY) { + out.write(bytes); + } + out.closeArchiveEntry(); + } + extractor = new ZipUnArchiver(); + } else { + extractor = new AbstractUnArchiver() { + @Override + protected void execute() { + try { + extractFile( + getSourceFile(), + getDestDirectory(), + new ByteArrayInputStream(bytes), + member, + new Date(1234567000000L), + kind == Kind.DIRECTORY, + 0755, + kind == Kind.SYMLINK ? value : null, + getFileMappers()); + } catch (IOException e) { + throw new ArchiverException("Cannot extract fixture", e); + } + } + + @Override + protected void execute(String path, File outputDirectory) { + throw new UnsupportedOperationException(); + } + }; + } + extractor.setSourceFile(archive.toFile()); + extractor.setDestDirectory(root.toFile()); + extractor.setFileMappers(new FileMapper[] {ignored -> name}); + extractor.extract(); + } + + /** A missing leaf must not hide the physical location of an existing symlinked parent on Windows. */ + @ParameterizedTest + @MethodSource("ordinaryEntries") + void rejectsOutsideParentBeforeCreatingDirectories(Format format, Kind kind) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path outside = Files.createDirectory(temp.resolve("outside")); + symlink(root.resolve("redirect"), outside); + assertThrows(ArchiverException.class, () -> extract(format, root, "redirect/new/entry", kind, "replacement")); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + assertTrue(Files.isSymbolicLink(root.resolve("redirect"))); + } + + @ParameterizedTest + @EnumSource(Format.class) + void rejectsOutsideParentWithoutChangingExistingFile(Format format) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path outside = Files.createDirectory(temp.resolve("outside")); + Path file = Files.writeString(outside.resolve("file"), "original"); + symlink(root.resolve("redirect"), outside); + assertThrows(ArchiverException.class, () -> extract(format, root, "redirect/file", Kind.FILE, "replacement")); + assertEquals("original", Files.readString(file)); + } + + /** Both a safe root alias and a contained directory link must work with nonexistent descendants. */ + @ParameterizedTest + @EnumSource(Format.class) + void acceptsAliasesWithMissingDescendants(Format format) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path real = Files.createDirectory(root.resolve("real")); + symlink(root.resolve("redirect"), Path.of("real")); + Path alias = temp.resolve("alias"); + symlink(alias, root); + extract(format, alias, "redirect/new/file", Kind.FILE, "relative"); + extract(format, alias, alias.resolve("redirect/other/file").toString(), Kind.FILE, "absolute"); + assertEquals("relative", Files.readString(real.resolve("new/file"))); + assertEquals("absolute", Files.readString(real.resolve("other/file"))); + } + + /** Validation and writes must agree on link/.. even when the lexical destination does not exist. */ + @ParameterizedTest + @EnumSource(Format.class) + void resolvesRootLinkBeforeParentComponent(Format format) throws Exception { + Path lexical = Files.createDirectory(temp.resolve("lexical")); + Path actual = Files.createDirectory(temp.resolve("actual")); + Path sub = Files.createDirectory(actual.resolve("sub")); + Path destination = Files.createDirectory(actual.resolve("only")); + symlink(lexical.resolve("hop"), sub); + Path root = lexical.resolve("hop/../only"); + extract(format, root, "file", Kind.FILE, "content"); + extract(format, root, root.resolve("absolute").toString(), Kind.FILE, "mapped"); + assertEquals("content", Files.readString(destination.resolve("file"))); + assertEquals("mapped", Files.readString(destination.resolve("absolute"))); + assertFalse(Files.exists(lexical.resolve("only"))); + } + + /** Ordinary entries must preserve final links and their targets, including dangling links. */ + @ParameterizedTest + @MethodSource("finalLinks") + void rejectsFinalSymlink(Format format, Kind kind, String location) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path target = (location.equals("inside") ? root : temp).resolve("target"); + if (!location.equals("missing")) { + if (kind == Kind.DIRECTORY) { + Files.createDirectory(target); + } else { + Files.writeString(target, "original"); + } + Files.setLastModifiedTime(target, FileTime.fromMillis(1500000000000L)); + } + Path link = root.resolve("entry"); + symlink(link, target); + assertThrows(ArchiverException.class, () -> extract(format, root, "entry", kind, "replacement")); + assertEquals(target, Files.readSymbolicLink(link)); + if (location.equals("missing")) { + assertFalse(Files.exists(target, LinkOption.NOFOLLOW_LINKS)); + } else if (kind == Kind.FILE) { + assertEquals("original", Files.readString(target)); + } else { + assertTrue(Files.isDirectory(target)); + } + if (!location.equals("missing")) { + assertEquals(1500000000000L, Files.getLastModifiedTime(target).toMillis()); + } + } + + /** A symlink member may name an outside target, but must not set metadata on that target. */ + @ParameterizedTest + @EnumSource(Format.class) + void symlinkMetadataDoesNotFollowTarget(Format format) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path target = Files.writeString(temp.resolve("target"), "original"); + symlink(temp.resolve("probe"), target); + Files.setLastModifiedTime(target, FileTime.fromMillis(1500000000000L)); + var attributes = Files.readAttributes(target, "basic:lastModifiedTime"); + var permissions = Files.getFileStore(target).supportsFileAttributeView("posix") + ? Files.getPosixFilePermissions(target) + : null; + extract(format, root, "entry", Kind.SYMLINK, target.toString()); + assertEquals(attributes, Files.readAttributes(target, "basic:lastModifiedTime")); + // Repeated extraction also preserves an already existing symlink and its target's metadata. + extract(format, root, "entry", Kind.SYMLINK, target.toString()); + assertEquals(target, Files.readSymbolicLink(root.resolve("entry"))); + assertEquals(attributes, Files.readAttributes(target, "basic:lastModifiedTime")); + if (permissions != null) { + assertEquals(permissions, Files.getPosixFilePermissions(target)); + } + assertEquals("original", Files.readString(target)); + } + + @ParameterizedTest + @EnumSource(Format.class) + @Timeout(5) + void rejectsSymlinkCycles(Format format) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + symlink(root.resolve("loop"), Path.of("loop")); + Throwable error = + assertThrows(ArchiverException.class, () -> extract(format, root, "loop/file", Kind.FILE, "content")); + while (error.getCause() != null) { + error = error.getCause(); + } + assertInstanceOf(FileSystemLoopException.class, error); + } + + /** Resolve long chains ourselves instead of depending on an operating system's whole-path link limit. */ + @ParameterizedTest + @EnumSource(Format.class) + void boundsSymlinkExpansions(Format format) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path real = Files.createDirectory(root.resolve("real")); + for (int i = 64; i >= 0; i--) { + symlink(root.resolve("link" + i), Path.of(i == 64 ? "real" : "link" + (i + 1))); + } + extract(format, root, "link1/accepted", Kind.FILE, "content"); + assertEquals("content", Files.readString(real.resolve("accepted"))); + Throwable error = assertThrows( + ArchiverException.class, () -> extract(format, root, "link0/rejected", Kind.FILE, "content")); + while (error.getCause() != null) { + error = error.getCause(); + } + assertInstanceOf(FileSystemLoopException.class, error); + assertFalse(Files.exists(real.resolve("rejected"))); + } +} diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java index a96875ac2..f9e0062f1 100644 --- a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.util.Enumeration; @@ -242,6 +243,41 @@ void rejectsSymlinkEscape() throws Exception { } } + /** Neither an existing output nor an excluded target may be a final symbolic link. */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "false, inside", "false, outside", "false, missing", + "true, inside", "true, outside", "true, missing" + }) + void rejectsFinalSymlinks(boolean targetLink, String location) throws Exception { + Path output = Files.createDirectory(temp.resolve("output")); + Path target = (location.equals("inside") ? output : temp).resolve("real"); + if (!location.equals("missing")) { + Files.writeString(target, "original"); + } + Path link = output.resolve(targetLink ? "file" : "link"); + Path retained = output.resolve(targetLink ? "link" : "file"); + Files.writeString(retained, "retained"); + try { + Files.createSymbolicLink(link, target); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + TarUnArchiver unarchiver = unarchiver(archive(false), output); + unarchiver.setFileSelectors(new FileSelector[] {file -> file.getName().equals("link")}); + assertThrows(org.codehaus.plexus.archiver.ArchiverException.class, unarchiver::extract); + assertEquals(target, Files.readSymbolicLink(link)); + assertEquals("retained", Files.readString(retained)); + if (location.equals("missing")) { + assertFalse(Files.exists(target, LinkOption.NOFOLLOW_LINKS)); + } else { + assertEquals("original", Files.readString(target)); + } + try (var files = Files.list(output)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".plexus-link-"))); + } + } + /** A duplicate data member does not change an alias bound to its earlier occurrence. */ @Test void preservesDuplicateOccurrencesAndArchiveOrder() throws Exception { From a5dfe8c3c4205332ec47621a60f33272896888f1 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 13:48:04 +0200 Subject: [PATCH 5/8] Keep hard-link chain tests within NTFS limits Extract 512 physical links and check their shared inode and timestamp. Exercise a separate 2000-link logical chain through TarFile content resolution without requiring thousands of filesystem links. --- .../plexus/archiver/tar/TarHardLinkTest.java | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java index f9e0062f1..77a5c5433 100644 --- a/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarHardLinkTest.java @@ -297,14 +297,14 @@ void preservesDuplicateOccurrencesAndArchiveOrder() throws Exception { assertFalse(Files.isSameFile(output.resolve("old-link"), output.resolve("new-link"))); } - /** Thousands of backward aliases link in one pass without staging their payload. */ + /** Backward aliases share an inode while staying below NTFS's limit of 1024 names per file. */ @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++) { + for (int i = 1; i <= 512; i++) { entry(out, "link" + i, "", "link" + (i - 1)); } } @@ -312,7 +312,39 @@ void longChainsUseOnePayloadAndDataMetadata() throws Exception { TarUnArchiver unarchiver = unarchiver(source, output); unarchiver.extract(); assertEquals("data", Files.readString(output.resolve("link0"))); - assertTrue(Files.isSameFile(output.resolve("link0"), output.resolve("link2000"))); + assertTrue(Files.isSameFile(output.resolve("link0"), output.resolve("link512"))); + assertEquals( + 1234567000000L, + Files.getLastModifiedTime(output.resolve("link512")).toMillis()); + } + + /** Large logical chains exercise iterative content resolution without requiring thousands of NTFS links. */ + @Test + void resolvesThousandsOfLinksWithoutFilesystemLinks() throws Exception { + Path source = Files.createTempFile(temp, "logical-chain", ".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)); + } + } + try (TarFile file = new TarFile(source.toFile())) { + Enumeration entries = file.getEntries(); + TarArchiveEntry last = null; + int count = 0; + while (entries.hasMoreElements()) { + last = (TarArchiveEntry) entries.nextElement(); + count++; + } + assertEquals(2001, count); + assertEquals("link2000", last.getName()); + assertEquals(0, last.getSize()); + TarResource resource = new TarResource(file, last); + assertEquals(4, resource.getSize()); + try (var contents = resource.getContents()) { + assertEquals("data", new String(contents.readAllBytes(), StandardCharsets.UTF_8)); + } + } } /** Content-reading selectors use the configured decoder without disrupting streaming extraction. */ From 6880922ac5499f675f553401553469ffb0e93499 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 14:12:12 +0200 Subject: [PATCH 6/8] Cache verified directory paths during extraction Reuse up to 1024 physical directory spellings after fresh type and file-identity checks, and clear the cache after each extraction. Resolve relative entry components from the already resolved root to avoid repeating its path walk. Keep symlinks, missing paths, and providers without file identities on fresh resolution. Cover directory replacement, newly created symlinks, and extractor reuse after failure. --- .../plexus/archiver/AbstractUnArchiver.java | 72 ++++++-- .../archiver/ExtractionPathCacheTest.java | 167 ++++++++++++++++++ 2 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 src/test/java/org/codehaus/plexus/archiver/ExtractionPathCacheTest.java diff --git a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java index c0f7bfc82..c42f14f9b 100644 --- a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java @@ -33,8 +33,10 @@ import java.util.ArrayList; import java.util.Date; import java.util.Deque; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import org.codehaus.plexus.archiver.util.ArchiveEntryUtils; @@ -72,6 +74,11 @@ protected Logger getLogger() { private List finalizers; + /** Directory spellings are reused only after a fresh NOFOLLOW identity check, within one extraction. */ + private Map resolvedDirectories; + + private record ResolvedDirectory(Object fileKey, Path path) {} + private FileSelector[] fileSelectors; /** @@ -139,16 +146,26 @@ public void setFileMappers(final FileMapper[] fileMappers) { @Override public final void extract() throws ArchiverException { - validate(); - execute(); - runArchiveFinalizers(); + resolvedDirectories = new LinkedHashMap<>(128, 0.75f, true); + try { + validate(); + execute(); + runArchiveFinalizers(); + } finally { + resolvedDirectories = null; + } } @Override public final void extract(final String path, final File outputDirectory) throws ArchiverException { - validate(path, outputDirectory); - execute(path, outputDirectory); - runArchiveFinalizers(); + resolvedDirectories = new LinkedHashMap<>(128, 0.75f, true); + try { + validate(path, outputDirectory); + execute(path, outputDirectory); + runArchiveFinalizers(); + } finally { + resolvedDirectories = null; + } } @Override @@ -353,8 +370,9 @@ protected final Path resolveExtractionPath(File directory, String name) throws I Path root = resolveExtractionRoot(directory); // Preserve the previous resolver's portable absolute names and platform-native relative names. Path portable = Path.of(name.replace('/', File.separatorChar).replace('\\', File.separatorChar)); - Path path = portable.isAbsolute() ? portable : root.resolve(Path.of(name)); - Path destination = resolvePath(path, false); + // The root was just resolved above. Relative entries need only walk their own components. + Path destination = + portable.isAbsolute() ? resolvePath(portable, false) : resolvePath(root, Path.of(name), false); if (!destination.startsWith(root)) { throw new ArchiverException("Entry is outside of the target directory (" + name + ")"); } @@ -365,11 +383,15 @@ protected final Path resolveExtractionPath(File directory, String name) throws I * Resolves existing components individually: Windows canonicalization of a missing leaf can leave its * symlinked parents unresolved, and whole-path normalization can erase a link before a later {@code ..}. */ - private static Path resolvePath(Path path, boolean followLastLink) throws IOException { + private Path resolvePath(Path path, boolean followLastLink) throws IOException { Path absolute = path.toAbsolutePath(); - Path current = absolute.getRoot(); + return resolvePath(absolute.getRoot(), absolute, followLastLink); + } + + /** Starts at a prefix already resolved during this check, without repeating its filesystem operations. */ + private Path resolvePath(Path current, Path path, boolean followLastLink) throws IOException { Deque remaining = new ArrayDeque<>(); - absolute.forEach(remaining::addLast); + path.forEach(remaining::addLast); int links = 0; while (!remaining.isEmpty()) { Path name = remaining.removeFirst(); @@ -408,12 +430,38 @@ private static Path resolvePath(Path path, boolean followLastLink) throws IOExce throw new NotDirectoryException(candidate.toString()); } // With no unresolved parent links or dots, this also handles Windows casing and short names. - current = candidate.toRealPath(); + current = resolveDirectory(candidate, attributes); } } return current; } + /** Avoids repeated real-path walks while still observing replacements and links on every visit. */ + private Path resolveDirectory(Path candidate, BasicFileAttributes attributes) throws IOException { + Object key = attributes.fileKey(); + // Reparse points and providers without file identities need fresh resolution. Do not cache file leaves. + if (resolvedDirectories == null || !attributes.isDirectory() || attributes.isOther() || key == null) { + return candidate.toRealPath(); + } + ResolvedDirectory cached = resolvedDirectories.get(candidate); + if (cached != null && key.equals(cached.fileKey())) { + return cached.path(); + } + Path resolved = candidate.toRealPath(); + // Only retain spellings that already match the resolved path; aliases still need fresh resolution. + if (candidate.equals(resolved)) { + resolvedDirectories.put(candidate, new ResolvedDirectory(key, resolved)); + if (resolvedDirectories.size() > 1024) { + var oldest = resolvedDirectories.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } else { + resolvedDirectories.remove(candidate); + } + return resolved; + } + /** * Counter for casing message emitted, visible for testing. */ diff --git a/src/test/java/org/codehaus/plexus/archiver/ExtractionPathCacheTest.java b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathCacheTest.java new file mode 100644 index 000000000..ed7ee72af --- /dev/null +++ b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathCacheTest.java @@ -0,0 +1,167 @@ +/* + * 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; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; + +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.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Changes directories between entries to ensure cached spellings never replace filesystem validation. */ +class ExtractionPathCacheTest { + @TempDir + Path temp; + + @FunctionalInterface + private interface Entries { + void extract(Extractor extractor) throws IOException; + } + + private static class Extractor extends AbstractUnArchiver { + Entries entries; + + @Override + protected void execute() { + try { + entries.extract(this); + } catch (IOException e) { + throw new ArchiverException("Cannot extract fixture", e); + } + } + + @Override + protected void execute(String path, File outputDirectory) { + execute(); + } + + void write(String name) throws IOException { + extractFile( + getSourceFile(), + getDestDirectory(), + new ByteArrayInputStream(new byte[] {1}), + name, + new Date(), + false, + null, + null, + null); + } + + void run(boolean explicitDirectory) { + if (explicitDirectory) { + extract("", getDestDirectory()); + } else { + extract(); + } + } + } + + private Extractor extractor(Path root) throws IOException { + Extractor extractor = new Extractor(); + extractor.setDestDirectory(root.toFile()); + extractor.setSourceFile(Files.createTempFile(temp, "source", ".archive").toFile()); + return extractor; + } + + private void symlink(Path link, Path target) throws IOException { + try { + Files.createSymbolicLink(link, target); + } catch (IOException | UnsupportedOperationException e) { + assumeTrue(false, "Symbolic links unavailable: " + e); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void observesDirectoryReplacedBySymlinkWithinExtraction(boolean explicitDirectory) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path outside = Files.createDirectory(temp.resolve("outside")); + Extractor extractor = extractor(root); + extractor.entries = current -> { + current.write("parent/first"); + Files.move(root.resolve("parent"), root.resolve("saved")); + symlink(root.resolve("parent"), outside); + assertThrows(ArchiverException.class, () -> current.write("parent/new/file")); + assertArrayEquals(new byte[] {1}, Files.readAllBytes(root.resolve("saved/first"))); + }; + extractor.run(explicitDirectory); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void observesPreviouslyMissingParent(boolean explicitDirectory) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path outside = Files.createDirectory(temp.resolve("outside")); + Extractor extractor = extractor(root); + extractor.entries = current -> { + current.write("missing/../first"); + symlink(root.resolve("missing"), outside); + assertThrows(ArchiverException.class, () -> current.write("missing/new/file")); + }; + extractor.run(explicitDirectory); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void canReuseExtractorAfterFailureAndSuccess(boolean explicitDirectory) throws Exception { + Path root = Files.createDirectory(temp.resolve("root")); + Path first = Files.createDirectory(root.resolve("first")); + Path second = Files.createDirectory(root.resolve("second")); + Path outside = Files.createDirectory(temp.resolve("outside")); + Path link = root.resolve("link"); + symlink(link, first); + Extractor extractor = extractor(root); + extractor.entries = current -> { + current.write("link/before"); + Files.delete(link); + symlink(link, outside); + current.write("link/rejected"); + }; + assertThrows(ArchiverException.class, () -> extractor.run(explicitDirectory)); + assertTrue(Files.exists(first.resolve("before"))); + + Files.delete(link); + symlink(link, second); + extractor.entries = current -> current.write("link/after"); + extractor.run(explicitDirectory); + assertTrue(Files.exists(second.resolve("after"))); + + Files.delete(link); + symlink(link, first); + extractor.entries = current -> current.write("link/reused"); + extractor.run(explicitDirectory); + assertTrue(Files.exists(first.resolve("reused"))); + assertFalse(Files.exists(second.resolve("reused"))); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } +} From 17a2ef13ceb3c2598d38088b0f841308876b1f34 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 14:35:50 +0200 Subject: [PATCH 7/8] Preserve Windows rooted paths during extraction Use the relative-path shortcut only for paths without a root. Resolve other forms against the extraction root through Path.resolve before checking containment. Cover rooted backslash, rooted forward-slash, and drive-relative mappings through shared extraction, TAR, and ZIP. --- .../plexus/archiver/AbstractUnArchiver.java | 5 +- .../plexus/archiver/ExtractionPathTest.java | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java index c42f14f9b..2c69922ae 100644 --- a/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java +++ b/src/main/java/org/codehaus/plexus/archiver/AbstractUnArchiver.java @@ -370,9 +370,10 @@ protected final Path resolveExtractionPath(File directory, String name) throws I Path root = resolveExtractionRoot(directory); // Preserve the previous resolver's portable absolute names and platform-native relative names. Path portable = Path.of(name.replace('/', File.separatorChar).replace('\\', File.separatorChar)); - // The root was just resolved above. Relative entries need only walk their own components. + Path entry = portable.isAbsolute() ? portable : Path.of(name); + // Only rootless entries can start at the resolved root. Windows rooted paths can be non-absolute. Path destination = - portable.isAbsolute() ? resolvePath(portable, false) : resolvePath(root, Path.of(name), false); + entry.getRoot() == null ? resolvePath(root, entry, false) : resolvePath(root.resolve(entry), false); if (!destination.startsWith(root)) { throw new ArchiverException("Entry is outside of the target directory (" + name + ")"); } diff --git a/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java index 2cd31c945..6b81931c4 100644 --- a/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/ExtractionPathTest.java @@ -72,6 +72,12 @@ private static Stream finalLinks() { .map(target -> Arguments.of(arguments.get()[0], arguments.get()[1], target))); } + private static Stream windowsRootedMappings() { + return Arrays.stream(Format.values()).flatMap(format -> Stream.of("rooted", "rooted-forward", "drive-relative") + .flatMap(spelling -> + Stream.of(false, true).map(contained -> Arguments.of(format, spelling, contained)))); + } + private void symlink(Path link, Path target) throws IOException { try { Files.createSymbolicLink(link, target); @@ -173,6 +179,47 @@ void rejectsOutsideParentWithoutChangingExistingFile(Format format) throws Excep assertEquals("original", Files.readString(file)); } + /** Windows rooted paths can be non-absolute and must retain Path.resolve semantics through mappers. */ + @ParameterizedTest + @MethodSource("windowsRootedMappings") + void preservesWindowsRootedMappings(Format format, String spelling, boolean contained) throws Exception { + assumeTrue(File.separatorChar == '\\', "Requires Windows path syntax"); + Path root = Files.createDirectory(temp.resolve("root")).toAbsolutePath(); + Path outside = Files.createDirectory(temp.resolve("outside")).toAbsolutePath(); + Path expected = (contained ? root : outside).resolve("file.txt"); + String mapped; + if (spelling.equals("drive-relative")) { + String drive = root.getRoot().toString(); + assumeTrue(drive.length() == 3 && drive.charAt(1) == ':', "Requires a drive-letter destination"); + mapped = drive.substring(0, 2) + (contained ? "file.txt" : "..\\outside\\file.txt"); + } else { + mapped = "\\" + root.getRoot().relativize(expected); + if (spelling.equals("rooted-forward")) { + mapped = mapped.replace('\\', '/'); + } + } + Path path = Path.of(mapped); + assertFalse(path.isAbsolute()); + assertNotNull(path.getRoot()); + String name = mapped; + if (contained) { + extract(format, root, name, Kind.FILE, "content"); + assertEquals("content", Files.readString(expected)); + try (var files = Files.list(root)) { + assertEquals(1, files.count()); + } + } else { + assertThrows(ArchiverException.class, () -> extract(format, root, name, Kind.FILE, "content")); + assertFalse(Files.exists(expected)); + try (var files = Files.list(root)) { + assertEquals(0, files.count()); + } + } + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } + /** Both a safe root alias and a contained directory link must work with nonexistent descendants. */ @ParameterizedTest @EnumSource(Format.class) From c26885816ae8f9d2ed18331fa826242385a53490 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sat, 26 Sep 2026 16:08:32 +0200 Subject: [PATCH 8/8] Use physical fixture roots in symlink traversal tests --- .../plexus/archiver/tar/TarSymlinkTraversalTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java index dcea414c3..65925b96b 100644 --- a/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java +++ b/src/test/java/org/codehaus/plexus/archiver/tar/TarSymlinkTraversalTest.java @@ -299,7 +299,7 @@ private Path rootThroughSymlinkAndParent() throws IOException { @ValueSource(strings = {"relative", "configured-absolute", "canonical-absolute"}) void checksChildrenOfRootThroughSymlinkAndParent(String spelling) throws Exception { Path output = rootThroughSymlinkAndParent(); - Path actual = output.toFile().getCanonicalFile().toPath(); + Path actual = temp.resolve("actual").toRealPath(); Files.createDirectory(actual.resolve("real")); symlink(actual.resolve("redirect"), Path.of("real")); String mapped = @@ -325,7 +325,7 @@ void checksChildrenOfRootThroughSymlinkAndParent(String spelling) throws Excepti void checksHardLinksUnderRootThroughSymlinkAndParent(boolean targetTraversal) throws Exception { requireLinks(); Path output = rootThroughSymlinkAndParent(); - Path actual = output.toFile().getCanonicalFile().toPath(); + Path actual = temp.resolve("actual").toRealPath(); Files.createDirectory(actual.resolve("real")); Files.writeString(actual.resolve("real/file"), "existing"); symlink(actual.resolve("redirect"), Path.of("real")); @@ -355,7 +355,7 @@ void checksHardLinksUnderRootThroughSymlinkAndParent(boolean targetTraversal) th void acceptsSafeChildrenOfRootThroughSymlinkAndParent(boolean reject) throws Exception { requireLinks(); Path output = rootThroughSymlinkAndParent(); - Path actual = output.toFile().getCanonicalFile().toPath(); + Path actual = temp.resolve("actual").toRealPath(); symlink(temp.resolve("lexical/real"), Path.of("elsewhere")); Path source = archive( TarUnArchiver.UntarCompressionMethod.NONE,