ArchiveInputStream.java

package xyz.apollosoftware.bibliothiki.compression;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import xyz.apollosoftware.bibliothiki.compression.formats.tar.TapeArchiveInputStream;
import xyz.apollosoftware.bibliothiki.compression.utils.PeekableInputStream;
import xyz.apollosoftware.bibliothiki.compression.formats.zip.ZipArchiveInputStream;

import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Objects;
import java.util.zip.GZIPInputStream;

import static xyz.apollosoftware.bibliothiki.compression.formats.tar.TapeArchiveInputStream.isGzipStream;
import static xyz.apollosoftware.bibliothiki.compression.formats.tar.TapeArchiveInputStream.isTarStream;
import static xyz.apollosoftware.bibliothiki.compression.formats.zip.ZipArchiveInputStream.isZipStream;

/**
 * Process an archive one entry at a time.
 */
public abstract class ArchiveInputStream implements Closeable {

    /**
     * The stream of compressed data.
     */
    protected final InputStream stream;

    /**
     * Construct an {@link ArchiveInputStream} from the given stream.
     *
     * <p>
     * The stream should not be exposed externally to the class hierarchy of
     * the {@link ArchiveInputStream}.
     *
     * @param stream The stream containing archive data.
     */
    public ArchiveInputStream(@NonNull final InputStream stream) {
        this.stream = Objects.requireNonNull(stream);
    }

    /**
     * Get the next entry in the archive.
     *
     * <p>
     * If there are no (more) entries in the archive, this method returns null,
     * instead.
     *
     * @return The {@link ArchiveEntry}, if there is one.
     * @see ArchiveEntry
     */
    @Nullable
    public abstract ArchiveEntry getNextEntry();

    /**
     * Write the current archive entry to the given {@link OutputStream}.
     *
     * <p>
     * The {@link #getNextEntry()} must have been called prior, or this method
     * will throw a {@link CompressionException}.
     *
     * <p>
     * A common pattern for writing to files, therefore, would be to invoke
     * {@link #getNextEntry()} and then write the file data with this method.
     *
     * <p>
     * <i>Some</i> implementations may return a {@link CompressionException} for
     * format or decompression failures. Implementations that do this will still
     * return {@link IOException} when an exception is raised writing to the
     * given output stream. In cases where it is not possible to disambiguate,
     * an {@link IOException} is always thrown instead.
     *
     * @param stream The stream to write the file data to.
     * @see #getNextEntry()
     * @throws IOException If writing to the given {@link OutputStream} fails
     *                     with an {@link IOException}.
     * @throws CompressionException If decompressing or reading an entry to
     *                              write fails.
     */
    public abstract void writeCurrentEntryTo(@NonNull OutputStream stream) throws IOException;

    /**
     * Close the current entry.
     *
     * <p>
     * This may be a no-op for some implementations.
     */
    public abstract void closeCurrentEntry();

    @Override
    public void close() {
        try {
            stream.close();
        } catch (final IOException ex) {
            throw new CompressionException("Failed to close archive", ex);
        }
    }

    /**
     * Attempts to automatically detect the archive format (and therefore the
     * {@link ArchiveInputStream} implementation) to use for the given stream.
     *
     * @param stream The stream to detect the input stream for.
     * @return The {@link ArchiveInputStream} implementation for the given
     *         stream format.
     * @throws CompressionException If the archive format is not supported.
     */
    @NonNull
    public static ArchiveInputStream detect(@NonNull InputStream stream) {

        if (isGzipStream(stream)) {
            try {
                stream = PeekableInputStream.ensurePeekable(new GZIPInputStream(stream));
            } catch (IOException ex) {
                throw new CompressionException("Failed to begin processing GZIP stream");
            }
        }

        if (isZipStream(stream)) {
            return new ZipArchiveInputStream(stream);
        }

        if (isTarStream(stream)) {
            return new TapeArchiveInputStream(stream);
        }

        throw new CompressionException("Unsupported archive format");
    }

}