blob: d1822e1a8e985b56b3351765b4115cc6b320a5b3 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.apache.commons.compress.archivers.tar;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import org.apache.commons.compress.UnhandledInputException;
import org.apache.commons.compress.archivers.zip.ZipEncoding;
import org.apache.commons.compress.archivers.zip.ZipEncodingHelper;
import org.apache.commons.compress.utils.ArchiveUtils;
import org.apache.commons.compress.utils.BoundedInputStream;
import org.apache.commons.compress.utils.BoundedArchiveInputStream;
import org.apache.commons.compress.utils.BoundedSeekableByteChannelInputStream;
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
/**
* The TarFile provides random access to UNIX archives.
* @since 1.21
*/
public class TarFile implements Closeable {
private static final int SMALL_BUFFER_SIZE = 256;
private final byte[] smallBuf = new byte[SMALL_BUFFER_SIZE];
private final SeekableByteChannel archive;
/**
* The encoding of the tar file
*/
private final ZipEncoding zipEncoding;
private final LinkedList<TarArchiveEntry> entries = new LinkedList<>();
private final int blockSize;
private final boolean lenient;
private final int recordSize;
private final ByteBuffer recordBuffer;
// the global sparse headers, this is only used in PAX Format 0.X
private final List<TarArchiveStructSparse> globalSparseHeaders = new ArrayList<>();
private boolean hasHitEOF;
/**
* The meta-data about the current entry
*/
private TarArchiveEntry currEntry;
// the global PAX header
private Map<String, String> globalPaxHeaders = new HashMap<>();
private final Map<String, List<InputStream>> sparseInputStreams = new HashMap<>();
/**
* Constructor for TarFile.
*
* @param content the content to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final byte[] content) throws IOException {
this(new SeekableInMemoryByteChannel(content));
}
/**
* Constructor for TarFile.
*
* @param content the content to use
* @param encoding the encoding to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final byte[] content, final String encoding) throws IOException {
this(new SeekableInMemoryByteChannel(content), TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, encoding, false);
}
/**
* Constructor for TarFile.
*
* @param content the content to use
* @param lenient when set to true illegal values for group/userid, mode, device numbers and timestamp will be
* ignored and the fields set to {@link TarArchiveEntry#UNKNOWN}. When set to false such illegal fields cause an
* exception instead.
* @throws IOException when reading the tar archive fails
*/
public TarFile(final byte[] content, final boolean lenient) throws IOException {
this(new SeekableInMemoryByteChannel(content), TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, null, lenient);
}
/**
* Constructor for TarFile.
*
* @param archive the file of the archive to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final File archive) throws IOException {
this(archive.toPath());
}
/**
* Constructor for TarFile.
*
* @param archive the file of the archive to use
* @param encoding the encoding to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final File archive, final String encoding) throws IOException {
this(archive.toPath(), encoding);
}
/**
* Constructor for TarFile.
*
* @param archive the file of the archive to use
* @param lenient when set to true illegal values for group/userid, mode, device numbers and timestamp will be
* ignored and the fields set to {@link TarArchiveEntry#UNKNOWN}. When set to false such illegal fields cause an
* exception instead.
* @throws IOException when reading the tar archive fails
*/
public TarFile(final File archive, final boolean lenient) throws IOException {
this(archive.toPath(), lenient);
}
/**
* Constructor for TarFile.
*
* @param archivePath the path of the archive to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final Path archivePath) throws IOException {
this(Files.newByteChannel(archivePath), TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, null, false);
}
/**
* Constructor for TarFile.
*
* @param archivePath the path of the archive to use
* @param encoding the encoding to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final Path archivePath, final String encoding) throws IOException {
this(Files.newByteChannel(archivePath), TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, encoding, false);
}
/**
* Constructor for TarFile.
*
* @param archivePath the path of the archive to use
* @param lenient when set to true illegal values for group/userid, mode, device numbers and timestamp will be
* ignored and the fields set to {@link TarArchiveEntry#UNKNOWN}. When set to false such illegal fields cause an
* exception instead.
* @throws IOException when reading the tar archive fails
*/
public TarFile(final Path archivePath, final boolean lenient) throws IOException {
this(Files.newByteChannel(archivePath), TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, null, lenient);
}
/**
* Constructor for TarFile.
*
* @param content the content to use
* @throws IOException when reading the tar archive fails
*/
public TarFile(final SeekableByteChannel content) throws IOException {
this(content, TarConstants.DEFAULT_BLKSIZE, TarConstants.DEFAULT_RCDSIZE, null, false);
}
/**
* Constructor for TarFile.
*
* @param archive the seekable byte channel to use
* @param blockSize the blocks size to use
* @param recordSize the record size to use
* @param encoding the encoding to use
* @param lenient when set to true illegal values for group/userid, mode, device numbers and timestamp will be
* ignored and the fields set to {@link TarArchiveEntry#UNKNOWN}. When set to false such illegal fields cause an
* exception instead.
* @throws IOException when reading the tar archive fails
*/
public TarFile(final SeekableByteChannel archive, final int blockSize, final int recordSize, final String encoding, final boolean lenient) throws IOException {
this.archive = archive;
this.hasHitEOF = false;
this.zipEncoding = ZipEncodingHelper.getZipEncoding(encoding);
this.recordSize = recordSize;
this.recordBuffer = ByteBuffer.allocate(this.recordSize);
this.blockSize = blockSize;
this.lenient = lenient;
TarArchiveEntry entry;
while ((entry = getNextTarEntry()) != null) {
entries.add(entry);
}
}
/**
* Get the next entry in this tar archive. This will skip
* to the end of the current entry, if there is one, and
* place the position of the channel at the header of the
* next entry, and read the header and instantiate a new
* TarEntry from the header bytes and return that entry.
* If there are no more entries in the archive, null will
* be returned to indicate that the end of the archive has
* been reached.
*
* @return The next TarEntry in the archive, or null if there is no next entry.
* @throws IOException when reading the next TarEntry fails
*/
private TarArchiveEntry getNextTarEntry() throws IOException {
if (isAtEOF()) {
return null;
}
try {
return getNextTarEntryInternal();
} catch (RuntimeException ex) {
throw new UnhandledInputException(ex);
}
}
private TarArchiveEntry getNextTarEntryInternal() throws IOException {
if (currEntry != null) {
// Skip to the end of the entry
repositionForwardTo(currEntry.getDataOffset() + currEntry.getSize());
throwExceptionIfPositionIsNotInArchive();
skipRecordPadding();
}
final ByteBuffer headerBuf = getRecord();
if (null == headerBuf) {
/* hit EOF */
currEntry = null;
return null;
}
try {
currEntry = new TarArchiveEntry(headerBuf.array(), zipEncoding, lenient, archive.position());
} catch (final IllegalArgumentException e) {
throw new IOException("Error detected parsing the header", e);
}
if (currEntry.isGNULongLinkEntry()) {
final byte[] longLinkData = getLongNameData();
if (longLinkData == null) {
// Bugzilla: 40334
// Malformed tar file - long link entry name not followed by
// entry
return null;
}
currEntry.setLinkName(zipEncoding.decode(longLinkData));
}
if (currEntry.isGNULongNameEntry()) {
final byte[] longNameData = getLongNameData();
if (longNameData == null) {
// Bugzilla: 40334
// Malformed tar file - long entry name not followed by
// entry
return null;
}
// COMPRESS-509 : the name of directories should end with '/'
final String name = zipEncoding.decode(longNameData);
currEntry.setName(name);
if (currEntry.isDirectory() && !name.endsWith("/")) {
currEntry.setName(name + "/");
}
}
if (currEntry.isGlobalPaxHeader()) { // Process Global Pax headers
readGlobalPaxHeaders();
}
try {
if (currEntry.isPaxHeader()) { // Process Pax headers
paxHeaders();
} else if (!globalPaxHeaders.isEmpty()) {
applyPaxHeadersToCurrentEntry(globalPaxHeaders, globalSparseHeaders);
}
} catch (NumberFormatException e) {
throw new IOException("Error detected parsing the pax header", e);
}
if (currEntry.isOldGNUSparse()) { // Process sparse files
readOldGNUSparse();
}
return currEntry;
}
/**
* Adds the sparse chunks from the current entry to the sparse chunks,
* including any additional sparse entries following the current entry.
*
* @throws IOException when reading the sparse entry fails
*/
private void readOldGNUSparse() throws IOException {
if (currEntry.isExtended()) {
TarArchiveSparseEntry entry;
do {
final ByteBuffer headerBuf = getRecord();
if (headerBuf == null) {
throw new IOException("premature end of tar archive. Didn't find extended_header after header with extended flag.");
}
entry = new TarArchiveSparseEntry(headerBuf.array());
currEntry.getSparseHeaders().addAll(entry.getSparseHeaders());
currEntry.setDataOffset(currEntry.getDataOffset() + recordSize);
} while (entry.isExtended());
}
// sparse headers are all done reading, we need to build
// sparse input streams using these sparse headers
buildSparseInputStreams();
}
/**
* Build the input streams consisting of all-zero input streams and non-zero input streams.
* When reading from the non-zero input streams, the data is actually read from the original input stream.
* The size of each input stream is introduced by the sparse headers.
*
* @implNote Some all-zero input streams and non-zero input streams have the size of 0. We DO NOT store the
* 0 size input streams because they are meaningless.
*/
private void buildSparseInputStreams() throws IOException {
final List<InputStream> streams = new ArrayList<>();
final List<TarArchiveStructSparse> sparseHeaders = currEntry.getOrderedSparseHeaders();
// Stream doesn't need to be closed at all as it doesn't use any resources
final InputStream zeroInputStream = new TarArchiveSparseZeroInputStream(); //NOSONAR
// logical offset into the extracted entry
long offset = 0;
long numberOfZeroBytesInSparseEntry = 0;
for (TarArchiveStructSparse sparseHeader : sparseHeaders) {
final long zeroBlockSize = sparseHeader.getOffset() - offset;
if (zeroBlockSize < 0) {
// sparse header says to move backwards inside of the extracted entry
throw new IOException("Corrupted struct sparse detected");
}
// only store the zero block if it is not empty
if (zeroBlockSize > 0) {
streams.add(new BoundedInputStream(zeroInputStream, zeroBlockSize));
numberOfZeroBytesInSparseEntry += zeroBlockSize;
}
// only store the input streams with non-zero size
if (sparseHeader.getNumbytes() > 0) {
final long start =
currEntry.getDataOffset() + sparseHeader.getOffset() - numberOfZeroBytesInSparseEntry;
if (start + sparseHeader.getNumbytes() < start) {
// possible integer overflow
throw new IOException("Unreadable TAR archive, sparse block offset or length too big");
}
streams.add(new BoundedSeekableByteChannelInputStream(start, sparseHeader.getNumbytes(), archive));
}
offset = sparseHeader.getOffset() + sparseHeader.getNumbytes();
}
sparseInputStreams.put(currEntry.getName(), streams);
}
/**
* Update the current entry with the read pax headers
* @param headers Headers read from the pax header
* @param sparseHeaders Sparse headers read from pax header
*/
private void applyPaxHeadersToCurrentEntry(final Map<String, String> headers, final List<TarArchiveStructSparse> sparseHeaders)
throws IOException {
currEntry.updateEntryFromPaxHeaders(headers);
currEntry.setSparseHeaders(sparseHeaders);
}
/**
* <p>
* For PAX Format 0.0, the sparse headers(GNU.sparse.offset and GNU.sparse.numbytes)
* may appear multi times, and they look like:
* <pre>
* GNU.sparse.size=size
* GNU.sparse.numblocks=numblocks
* repeat numblocks times
* GNU.sparse.offset=offset
* GNU.sparse.numbytes=numbytes
* end repeat
* </pre>
*
* <p>
* For PAX Format 0.1, the sparse headers are stored in a single variable : GNU.sparse.map
* <pre>
* GNU.sparse.map
* Map of non-null data chunks. It is a string consisting of comma-separated values "offset,size[,offset-1,size-1...]"
* </pre>
*
* <p>
* For PAX Format 1.X:
* <br>
* The sparse map itself is stored in the file data block, preceding the actual file data.
* It consists of a series of decimal numbers delimited by newlines. The map is padded with nulls to the nearest block boundary.
* The first number gives the number of entries in the map. Following are map entries, each one consisting of two numbers
* giving the offset and size of the data block it describes.
* @throws IOException
*/
private void paxHeaders() throws IOException {
List<TarArchiveStructSparse> sparseHeaders = new ArrayList<>();
final Map<String, String> headers;
try (final InputStream input = getInputStream(currEntry)) {
headers = TarUtils.parsePaxHeaders(input, sparseHeaders, globalPaxHeaders);
}
// for 0.1 PAX Headers
if (headers.containsKey("GNU.sparse.map")) {
sparseHeaders = new ArrayList<>(TarUtils.parseFromPAX01SparseHeaders(headers.get("GNU.sparse.map")));
}
getNextTarEntry(); // Get the actual file entry
if (currEntry == null) {
throw new IOException("premature end of tar archive. Didn't find any entry after PAX header.");
}
applyPaxHeadersToCurrentEntry(headers, sparseHeaders);
// for 1.0 PAX Format, the sparse map is stored in the file data block
if (currEntry.isPaxGNU1XSparse()) {
try (final InputStream input = getInputStream(currEntry)) {
sparseHeaders = TarUtils.parsePAX1XSparseHeaders(input, recordSize);
}
currEntry.setSparseHeaders(sparseHeaders);
// data of the entry is after the pax gnu entry. So we need to update the data position once again
currEntry.setDataOffset(currEntry.getDataOffset() + recordSize);
}
// sparse headers are all done reading, we need to build
// sparse input streams using these sparse headers
buildSparseInputStreams();
}
private void readGlobalPaxHeaders() throws IOException {
try (InputStream input = getInputStream(currEntry)) {
globalPaxHeaders = TarUtils.parsePaxHeaders(input, globalSparseHeaders, globalPaxHeaders);
}
getNextTarEntry(); // Get the actual file entry
if (currEntry == null) {
throw new IOException("Error detected parsing the pax header");
}
}
/**
* Get the next entry in this tar archive as longname data.
*
* @return The next entry in the archive as longname data, or null.
* @throws IOException on error
*/
private byte[] getLongNameData() throws IOException {
final ByteArrayOutputStream longName = new ByteArrayOutputStream();
int length;
try (final InputStream in = getInputStream(currEntry)) {
while ((length = in.read(smallBuf)) >= 0) {
longName.write(smallBuf, 0, length);
}
}
getNextTarEntry();
if (currEntry == null) {
// Bugzilla: 40334
// Malformed tar file - long entry name not followed by entry
return null;
}
byte[] longNameData = longName.toByteArray();
// remove trailing null terminator(s)
length = longNameData.length;
while (length > 0 && longNameData[length - 1] == 0) {
--length;
}
if (length != longNameData.length) {
final byte[] l = new byte[length];
System.arraycopy(longNameData, 0, l, 0, length);
longNameData = l;
}
return longNameData;
}
/**
* The last record block should be written at the full size, so skip any
* additional space used to fill a record after an entry
*
* @throws IOException when skipping the padding of the record fails
*/
private void skipRecordPadding() throws IOException {
if (!isDirectory() && currEntry.getSize() > 0 && currEntry.getSize() % recordSize != 0) {
final long numRecords = (currEntry.getSize() / recordSize) + 1;
final long padding = (numRecords * recordSize) - currEntry.getSize();
repositionForwardBy(padding);
throwExceptionIfPositionIsNotInArchive();
}
}
private void repositionForwardTo(final long newPosition) throws IOException {
final long currPosition = archive.position();
if (newPosition < currPosition) {
throw new IOException("trying to move backwards inside of the archive");
}
archive.position(newPosition);
}
private void repositionForwardBy(final long offset) throws IOException {
repositionForwardTo(archive.position() + offset);
}
/**
* Checks if the current position of the SeekableByteChannel is in the archive.
* @throws IOException If the position is not in the archive
*/
private void throwExceptionIfPositionIsNotInArchive() throws IOException {
if (archive.size() < archive.position()) {
throw new IOException("Truncated TAR archive");
}
}
/**
* Get the next record in this tar archive. This will skip
* over any remaining data in the current entry, if there
* is one, and place the input stream at the header of the
* next entry.
*
* <p>If there are no more entries in the archive, null will be
* returned to indicate that the end of the archive has been
* reached. At the same time the {@code hasHitEOF} marker will be
* set to true.</p>
*
* @return The next TarEntry in the archive, or null if there is no next entry.
* @throws IOException when reading the next TarEntry fails
*/
private ByteBuffer getRecord() throws IOException {
ByteBuffer headerBuf = readRecord();
setAtEOF(isEOFRecord(headerBuf));
if (isAtEOF() && headerBuf != null) {
// Consume rest
tryToConsumeSecondEOFRecord();
consumeRemainderOfLastBlock();
headerBuf = null;
}
return headerBuf;
}
/**
* Tries to read the next record resetting the position in the
* archive if it is not a EOF record.
*
* <p>This is meant to protect against cases where a tar
* implementation has written only one EOF record when two are
* expected. Actually this won't help since a non-conforming
* implementation likely won't fill full blocks consisting of - by
* default - ten records either so we probably have already read
* beyond the archive anyway.</p>
*
* @throws IOException if reading the record of resetting the position in the archive fails
*/
private void tryToConsumeSecondEOFRecord() throws IOException {
boolean shouldReset = true;
try {
shouldReset = !isEOFRecord(readRecord());
} finally {
if (shouldReset) {
archive.position(archive.position() - recordSize);
}
}
}
/**
* This method is invoked once the end of the archive is hit, it
* tries to consume the remaining bytes under the assumption that
* the tool creating this archive has padded the last block.
*/
private void consumeRemainderOfLastBlock() throws IOException {
final long bytesReadOfLastBlock = archive.position() % blockSize;
if (bytesReadOfLastBlock > 0) {
repositionForwardBy(blockSize - bytesReadOfLastBlock);
}
}
/**
* Read a record from the input stream and return the data.
*
* @return The record data or null if EOF has been hit.
* @throws IOException if reading from the archive fails
*/
private ByteBuffer readRecord() throws IOException {
recordBuffer.rewind();
final int readNow = archive.read(recordBuffer);
if (readNow != recordSize) {
return null;
}
return recordBuffer;
}
/**
* Get all TAR Archive Entries from the TarFile
*
* @return All entries from the tar file
*/
public List<TarArchiveEntry> getEntries() {
return new ArrayList<>(entries);
}
private boolean isEOFRecord(final ByteBuffer headerBuf) {
return headerBuf == null || ArchiveUtils.isArrayZero(headerBuf.array(), recordSize);
}
protected final boolean isAtEOF() {
return hasHitEOF;
}
protected final void setAtEOF(final boolean b) {
hasHitEOF = b;
}
private boolean isDirectory() {
return currEntry != null && currEntry.isDirectory();
}
/**
* Get the input stream for the provided Tar Archive Entry
* @param entry Entry to get the input stream from
* @return Input stream of the provided entry
*/
public InputStream getInputStream(final TarArchiveEntry entry) throws IOException {
try {
return new BoundedTarEntryInputStream(entry, archive);
} catch (RuntimeException ex) {
throw new IOException("Corrupted TAR archive. Can't read entry", ex);
}
}
@Override
public void close() throws IOException {
archive.close();
}
private final class BoundedTarEntryInputStream extends BoundedArchiveInputStream {
private final SeekableByteChannel channel;
private final TarArchiveEntry entry;
private long entryOffset;
private int currentSparseInputStreamIndex;
BoundedTarEntryInputStream(final TarArchiveEntry entry, final SeekableByteChannel channel) {
super(entry.getDataOffset(), entry.getRealSize());
this.entry = entry;
this.channel = channel;
}
@Override
protected int read(final long pos, final ByteBuffer buf) throws IOException {
if (entryOffset >= entry.getRealSize()) {
return -1;
}
final int totalRead;
try {
if (entry.isSparse()) {
totalRead = readSparse(entryOffset, buf, buf.limit());
} else {
totalRead = readArchive(pos, buf);
}
} catch (RuntimeException ex) {
throw new UnhandledInputException(ex);
}
if (totalRead == -1) {
if (buf.array().length > 0) {
throw new IOException("Truncated TAR archive");
}
setAtEOF(true);
} else {
entryOffset += totalRead;
buf.flip();
}
return totalRead;
}
private int readSparse(final long pos, final ByteBuffer buf, final int numToRead) throws IOException {
// if there are no actual input streams, just read from the original archive
final List<InputStream> entrySparseInputStreams = sparseInputStreams.get(entry.getName());
if (entrySparseInputStreams == null || entrySparseInputStreams.isEmpty()) {
return readArchive(entry.getDataOffset() + pos, buf);
}
if (currentSparseInputStreamIndex >= entrySparseInputStreams.size()) {
return -1;
}
final InputStream currentInputStream = entrySparseInputStreams.get(currentSparseInputStreamIndex);
final byte[] bufArray = new byte[numToRead];
final int readLen = currentInputStream.read(bufArray);
if (readLen != -1) {
buf.put(bufArray, 0, readLen);
}
// if the current input stream is the last input stream,
// just return the number of bytes read from current input stream
if (currentSparseInputStreamIndex == entrySparseInputStreams.size() - 1) {
return readLen;
}
// if EOF of current input stream is meet, open a new input stream and recursively call read
if (readLen == -1) {
currentSparseInputStreamIndex++;
return readSparse(pos, buf, numToRead);
}
// if the rest data of current input stream is not long enough, open a new input stream
// and recursively call read
if (readLen < numToRead) {
currentSparseInputStreamIndex++;
final int readLenOfNext = readSparse(pos + readLen, buf, numToRead - readLen);
if (readLenOfNext == -1) {
return readLen;
}
return readLen + readLenOfNext;
}
// if the rest data of current input stream is enough(which means readLen == len), just return readLen
return readLen;
}
private int readArchive(final long pos, final ByteBuffer buf) throws IOException {
channel.position(pos);
return channel.read(buf);
}
}
}