LhaArchiveInputStream.java

/*
 * 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
 *
 *   https://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.lha;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.zip.Checksum;

import org.apache.commons.codec.digest.Crc16;
import org.apache.commons.compress.archivers.AbstractArchiveBuilder;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.ArchiveException;
import org.apache.commons.compress.archivers.ArchiveInputStream;
import org.apache.commons.compress.archivers.zip.ZipUtil;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.BoundedInputStream;
import org.apache.commons.io.input.ChecksumInputStream;

/**
 * LHA archive format as an InputStream.
 *
 * @NotThreadSafe
 * @since 1.29.0
 */
public class LhaArchiveInputStream extends ArchiveInputStream<LhaArchiveEntry> {

    /**
     * Builds a new {@link LhaArchiveInputStream}.
     * <p>
     * For example:
     * </p>
     *
     * <pre>{@code
     *
     * LhaArchiveInputStream in = LhaArchiveInputStream.builder().setPath(inputPath).setCharset(StandardCharsets.UTF_8).get();
     * }</pre>
     */
    public static class Builder extends AbstractArchiveBuilder<LhaArchiveInputStream, Builder> {

        /**
         * The file separator char, defaults to {@link File#separatorChar}.
         */
        private char fileSeparatorChar = File.separatorChar;

        /**
         * Constructs a new instance.
         */
        private Builder() {
            setCharset(DEFAULT_CHARSET);
        }

        /**
         * Gets a new LhaArchiveInputStream.
         *
         * @return a new LhaArchiveInputStream.
         * @throws IOException if an I/O error occurs.
         */
        @Override
        public LhaArchiveInputStream get() throws IOException {
            return new LhaArchiveInputStream(this);
        }

        /**
         * Sets the file separator char. Package private for testing.
         *
         * @param fileSeparatorChar the file separator char, defaults to {@link File#separatorChar}.
         * @return {@code this} instance.
         */
        Builder setFileSeparatorChar(final char fileSeparatorChar) {
            this.fileSeparatorChar = fileSeparatorChar;
            return this;
        }
    }

    // Fields that are the same across all header levels
    private static final int HEADER_GENERIC_MINIMUM_HEADER_LENGTH = 22;

    private static final int HEADER_GENERIC_OFFSET_COMPRESSION_METHOD = 2;

    private static final int HEADER_GENERIC_OFFSET_HEADER_LEVEL = 20;

    // Header Level 0
    private static final int HEADER_LEVEL_0_OFFSET_HEADER_SIZE = 0;

    private static final int HEADER_LEVEL_0_OFFSET_HEADER_CHECKSUM = 1;

    private static final int HEADER_LEVEL_0_OFFSET_COMPRESSED_SIZE = 7;

    private static final int HEADER_LEVEL_0_OFFSET_ORIGINAL_SIZE = 11;

    private static final int HEADER_LEVEL_0_OFFSET_LAST_MODIFIED_DATE_TIME = 15;

    private static final int HEADER_LEVEL_0_OFFSET_FILENAME_LENGTH = 21;

    private static final int HEADER_LEVEL_0_OFFSET_FILENAME = 22;

    // Header Level 1
    private static final int HEADER_LEVEL_1_OFFSET_BASE_HEADER_SIZE = 0;

    private static final int HEADER_LEVEL_1_OFFSET_BASE_HEADER_CHECKSUM = 1;

    private static final int HEADER_LEVEL_1_OFFSET_SKIP_SIZE = 7;

    private static final int HEADER_LEVEL_1_OFFSET_ORIGINAL_SIZE = 11;

    private static final int HEADER_LEVEL_1_OFFSET_LAST_MODIFIED_DATE_TIME = 15;

    private static final int HEADER_LEVEL_1_OFFSET_FILENAME_LENGTH = 21;

    private static final int HEADER_LEVEL_1_OFFSET_FILENAME = 22;

    // Header Level 2
    private static final int HEADER_LEVEL_2_MINIMUM_HEADER_LENGTH = 26;

    private static final int HEADER_LEVEL_2_OFFSET_HEADER_SIZE = 0;

    private static final int HEADER_LEVEL_2_OFFSET_COMPRESSED_SIZE = 7;

    private static final int HEADER_LEVEL_2_OFFSET_ORIGINAL_SIZE = 11;

    private static final int HEADER_LEVEL_2_OFFSET_LAST_MODIFIED_DATE_TIME = 15;

    private static final int HEADER_LEVEL_2_OFFSET_CRC = 21;

    private static final int HEADER_LEVEL_2_OFFSET_OS_ID = 23;

    private static final int HEADER_LEVEL_2_OFFSET_FIRST_EXTENDED_HEADER_SIZE = 24;

    // Extended header types
    private static final int EXTENDED_HEADER_TYPE_COMMON = 0x00;

    private static final int EXTENDED_HEADER_TYPE_FILENAME = 0x01;

    private static final int EXTENDED_HEADER_TYPE_DIRECTORY_NAME = 0x02;

    private static final int EXTENDED_HEADER_TYPE_MSDOS_FILE_ATTRIBUTES = 0x40;

    private static final int EXTENDED_HEADER_TYPE_UNIX_PERMISSION = 0x50;

    private static final int EXTENDED_HEADER_TYPE_UNIX_UID_GID = 0x51;

    private static final int EXTENDED_HEADER_TYPE_UNIX_TIMESTAMP = 0x54;

    /**
     * Length in bytes of the next extended header size field.
     */
    private static final int EXTENDED_HEADER_NEXT_HEADER_SIZE_LENGTH = 2;

    /**
     * Minimum extended header length.
     */
    private static final int MIN_EXTENDED_HEADER_LENGTH = 3;

    private static final int EXTENDED_HEADER_TYPE_COMMON_MIN_PAYLOAD_LENGTH = 2;

    private static final int EXTENDED_HEADER_TYPE_MSDOS_FILE_ATTRIBUTES_PAYLOAD_LENGTH = 2;

    private static final int EXTENDED_HEADER_TYPE_UNIX_PERMISSION_PAYLOAD_LENGTH = 2;

    private static final int EXTENDED_HEADER_TYPE_UNIX_UID_GID_PAYLOAD_LENGTH = 4;

    private static final int EXTENDED_HEADER_TYPE_UNIX_TIMESTAMP_PAYLOAD_LENGTH = 4;

    // Compression methods
    private static final String COMPRESSION_METHOD_DIRECTORY = "-lhd-"; // Directory entry

    private static final String COMPRESSION_METHOD_LH0 = "-lh0-";

    private static final String COMPRESSION_METHOD_LH4 = "-lh4-";

    private static final String COMPRESSION_METHOD_LH5 = "-lh5-";

    private static final String COMPRESSION_METHOD_LH6 = "-lh6-";

    private static final String COMPRESSION_METHOD_LH7 = "-lh7-";

    private static final String COMPRESSION_METHOD_LZ4 = "-lz4-";

    /**
     * Maximum length of a pathname.
     */
    private static final int MAX_PATHNAME_LENGTH = 4096;

    /**
     * Default charset for decoding filenames.
     */
    private static final Charset DEFAULT_CHARSET = StandardCharsets.US_ASCII;

    /**
     * Creates a new builder.
     *
     * @return a new builder.
     */
    public static Builder builder() {
        return new Builder();
    }

    /**
     * Gets a byte array from the ByteBuffer at the specified position and length. This is needed until this repo has been updated to use Java 9+ where we can
     * use buffer.get(position, dst) directly.
     *
     * @param buffer   the buffer to get the byte array from.
     * @param position the position in the buffer to start reading from.
     * @param dst      the destination byte array to fill.
     */
    private static void byteBufferGet(final ByteBuffer buffer, final int position, final byte[] dst) {
        for (int i = 0; i < dst.length; i++) {
            dst[i] = buffer.get(position + i);
        }
    }

    /**
     * Gets the compression method from the header. It is always located at the same offset for all header levels.
     *
     * @param buffer the buffer containing the header data.
     * @return compression method, e.g. -lh5-.
     * @throws ArchiveException if the compression method is invalid.
     */
    static String getCompressionMethod(final ByteBuffer buffer) throws ArchiveException {
        final byte[] compressionMethodBuffer = new byte[5];
        byteBufferGet(buffer, HEADER_GENERIC_OFFSET_COMPRESSION_METHOD, compressionMethodBuffer);
        // Validate the compression method
        if (compressionMethodBuffer[0] == '-' && Character.isLowerCase(compressionMethodBuffer[1]) && Character.isLowerCase(compressionMethodBuffer[2])
                && (Character.isLowerCase(compressionMethodBuffer[3]) || Character.isDigit(compressionMethodBuffer[3])) && compressionMethodBuffer[4] == '-') {
            return new String(compressionMethodBuffer, StandardCharsets.US_ASCII);
        }
        throw new ArchiveException("Invalid compression method: 0x%02x 0x%02x 0x%02x 0x%02x 0x%02x", compressionMethodBuffer[0], compressionMethodBuffer[1],
                compressionMethodBuffer[2], compressionMethodBuffer[3], compressionMethodBuffer[4]);
    }

    /**
     * Checks if the signature matches what is expected for an LHA file. There is no specific signature for LHA files, so this method checks if the header level
     * and the compression method are valid for an LHA archive. The signature must be at least the minimum header length of 22 bytes for this check to work
     * properly.
     *
     * @param signature the bytes to check.
     * @param length    the number of bytes to check.
     * @return true, if this stream is an LHA archive stream, false otherwise.
     */
    public static boolean matches(final byte[] signature, final int length) {
        if (signature.length < HEADER_GENERIC_MINIMUM_HEADER_LENGTH || length < HEADER_GENERIC_MINIMUM_HEADER_LENGTH) {
            return false;
        }
        final ByteBuffer header = ByteBuffer.wrap(signature).order(ByteOrder.LITTLE_ENDIAN);
        // Determine header level. Only levels 0-2 are supported for reading.
        final byte headerLevel = header.get(HEADER_GENERIC_OFFSET_HEADER_LEVEL);
        if (headerLevel < 0 || headerLevel > 2) {
            return false;
        }
        // Check if the compression method is valid for LHA archives
        try {
            getCompressionMethod(header);
        } catch (final ArchiveException e) {
            return false;
        }
        return true;
    }

    private final char fileSeparatorChar;

    private LhaArchiveEntry currentEntry;

    private InputStream currentCompressedStream;

    private InputStream currentDecompressedStream;

    private LhaArchiveInputStream(final Builder builder) throws IOException {
        super(builder);
        this.fileSeparatorChar = builder.fileSeparatorChar;
    }

    /**
     * Create a new ByteBuffer slice from the provided buffer at the specified position and length. This is needed until this repo has been updated to use Java
     * 9+ where we can use buffer.position(position).slice().limit(length) directly.
     *
     * @param buffer   the buffer to slice from.
     * @param position the position in the buffer to start slicing from.
     * @param length   the length of the slice.
     * @return a new ByteBuffer slice with the specified position and length.
     */
    private ByteBuffer byteBufferSlice(final ByteBuffer buffer, final int position, final int length) {
        return ByteBuffer.wrap(buffer.array(), position, length);
    }

    /**
     * Calculate the CRC16 checksum of the provided buffers.
     *
     * @param buffers the buffers to calculate the CRC16 checksum for.
     * @return CRC16 checksum.
     */
    private long calculateCRC16(final ByteBuffer... buffers) {
        final Checksum crc = Crc16.arc();
        for (final ByteBuffer buffer : buffers) {
            crc.update(buffer.array(), 0, buffer.limit());
        }
        return crc.getValue();
    }

    /**
     * Calculate the header sum for level 0 and 1 headers. The checksum is calculated by summing the value of all bytes in the header except for the first two
     * bytes (header length and header checksum) and get the low 8 bits.
     *
     * @param buffer the buffer containing the header.
     * @return checksum.
     */
    private int calculateHeaderChecksum(final ByteBuffer buffer) {
        int sum = 0;
        for (int i = 2; i < buffer.limit(); i++) {
            sum += Byte.toUnsignedInt(buffer.get(i));
        }
        return sum & 0xff;
    }

    @Override
    public boolean canReadEntryData(final ArchiveEntry archiveEntry) {
        return currentDecompressedStream != null;
    }

    @Override
    public LhaArchiveEntry getNextEntry() throws IOException {
        if (this.currentCompressedStream != null) {
            // Consume the entire compressed stream to end up at the next entry
            IOUtils.consume(this.currentCompressedStream);
            this.currentCompressedStream = null;
            this.currentDecompressedStream = null;
        }
        this.currentEntry = readHeader();
        return this.currentEntry;
    }

    /**
     * Gets the pathname from the current position in the provided buffer. Any 0xFF bytes and '\' chars will be converted into the configured file path
     * separator char. Any leading file path separator char will be removed to avoid extracting to absolute locations.
     *
     * @param buffer         the buffer where to get the pathname from.
     * @param pathnameLength the length of the pathname.
     * @return pathname.
     * @throws ArchiveException if the pathname is too long.
     */
    String getPathname(final ByteBuffer buffer, final int pathnameLength) throws ArchiveException {
        // Check pathname length to ensure we don't allocate too much memory
        if (pathnameLength > MAX_PATHNAME_LENGTH) {
            throw new ArchiveException("Pathname is longer than the maximum allowed (%d > %d)", pathnameLength, MAX_PATHNAME_LENGTH);
        }
        if (pathnameLength < 0) {
            throw new ArchiveException("Pathname length is negative");
        }
        if (pathnameLength > buffer.limit() - buffer.position()) {
            throw new ArchiveException("Invalid pathname length");
        }
        final byte[] pathnameBuffer = new byte[pathnameLength];
        buffer.get(pathnameBuffer);
        // Split the pathname into parts by 0xFF bytes
        final StringBuilder pathnameStringBuilder = new StringBuilder();
        int start = 0;
        for (int i = 0; i < pathnameLength; i++) {
            if (pathnameBuffer[i] == (byte) 0xFF) {
                if (i > start) {
                    // Decode the path segment into a string using the specified charset and append it to the result
                    pathnameStringBuilder.append(new String(pathnameBuffer, start, i - start, getCharset())).append(fileSeparatorChar);
                }
                start = i + 1; // Move start to the next segment
            }
        }
        // Append the last segment if it exists
        if (start < pathnameLength) {
            pathnameStringBuilder.append(new String(pathnameBuffer, start, pathnameLength - start, getCharset()));
        }
        String pathname = pathnameStringBuilder.toString();
        // If the path separator char is not '\', replace all '\' characters with the path separator char
        if (fileSeparatorChar != '\\') {
            pathname = pathname.replace('\\', fileSeparatorChar);
        }
        // Remove leading file separator chars to avoid extracting to absolute locations
        while (pathname.length() > 0 && pathname.charAt(0) == fileSeparatorChar) {
            pathname = pathname.substring(1);
        }
        return pathname;
    }

    /**
     * Tests whether the compression method is a directory entry.
     *
     * @param compressionMethod the compression method.
     * @return true if the compression method is a directory entry, false otherwise.
     */
    private boolean isDirectory(final String compressionMethod) {
        return COMPRESSION_METHOD_DIRECTORY.equals(compressionMethod);
    }

    /**
     * Parse the extended header and set the values in the provided entry.
     *
     * @param extendedHeaderBuffer the buffer containing the extended header.
     * @param entryBuilder         the entry builder to set the values in.
     * @throws ArchiveException Thrown on invalid input.
     */
    void parseExtendedHeader(final ByteBuffer extendedHeaderBuffer, final LhaArchiveEntry.Builder entryBuilder) throws ArchiveException {
        final int extendedHeaderLength = extendedHeaderBuffer.limit() - extendedHeaderBuffer.position();
        if (extendedHeaderLength < MIN_EXTENDED_HEADER_LENGTH) {
            throw new ArchiveException("Invalid extended header length");
        }
        final int extendedHeaderType = Byte.toUnsignedInt(extendedHeaderBuffer.get());
        switch (extendedHeaderType) {
        case EXTENDED_HEADER_TYPE_COMMON: {
            // Common header
            if (extendedHeaderLength < MIN_EXTENDED_HEADER_LENGTH + EXTENDED_HEADER_TYPE_COMMON_MIN_PAYLOAD_LENGTH) {
                throw new ArchiveException("Invalid extended header length");
            }
            final int crcPos = extendedHeaderBuffer.position(); // Save the current position to be able to set the header CRC later
            // Header CRC
            entryBuilder.setHeaderCrc(Short.toUnsignedInt(extendedHeaderBuffer.getShort()));
            // Set header CRC to zero to be able to later compute the CRC of the full header
            extendedHeaderBuffer.putShort(crcPos, (short) 0);
            break;
        }
        case EXTENDED_HEADER_TYPE_FILENAME: {
            // File name header
            final int filenameLength = extendedHeaderBuffer.limit() - extendedHeaderBuffer.position() - EXTENDED_HEADER_NEXT_HEADER_SIZE_LENGTH;
            final String filename = getPathname(extendedHeaderBuffer, filenameLength);
            entryBuilder.setFileName(filename);
            break;
        }
        case EXTENDED_HEADER_TYPE_DIRECTORY_NAME: {
            // Directory name header
            final int directoryNameLength = extendedHeaderBuffer.limit() - extendedHeaderBuffer.position() - EXTENDED_HEADER_NEXT_HEADER_SIZE_LENGTH;
            final String directoryName = getPathname(extendedHeaderBuffer, directoryNameLength);
            if (directoryName.length() > 0 && directoryName.charAt(directoryName.length() - 1) != fileSeparatorChar) {
                // If the directory name does not end with a file separator, append it
                entryBuilder.setDirectoryName(directoryName + fileSeparatorChar);
            } else {
                entryBuilder.setDirectoryName(directoryName);
            }
            break;
        }
        case EXTENDED_HEADER_TYPE_MSDOS_FILE_ATTRIBUTES:
            // MS-DOS file attributes
            if (extendedHeaderLength != MIN_EXTENDED_HEADER_LENGTH + EXTENDED_HEADER_TYPE_MSDOS_FILE_ATTRIBUTES_PAYLOAD_LENGTH) {
                throw new ArchiveException("Invalid extended header length");
            }
            entryBuilder.setMsdosFileAttributes(Short.toUnsignedInt(extendedHeaderBuffer.getShort()));
            break;
        case EXTENDED_HEADER_TYPE_UNIX_PERMISSION:
            // UNIX file permission
            if (extendedHeaderLength != MIN_EXTENDED_HEADER_LENGTH + EXTENDED_HEADER_TYPE_UNIX_PERMISSION_PAYLOAD_LENGTH) {
                throw new ArchiveException("Invalid extended header length");
            }
            entryBuilder.setUnixPermissionMode(Short.toUnsignedInt(extendedHeaderBuffer.getShort()));
            break;
        case EXTENDED_HEADER_TYPE_UNIX_UID_GID:
            // UNIX group/user ID
            if (extendedHeaderLength != MIN_EXTENDED_HEADER_LENGTH + EXTENDED_HEADER_TYPE_UNIX_UID_GID_PAYLOAD_LENGTH) {
                throw new ArchiveException("Invalid extended header length");
            }
            entryBuilder.setUnixGroupId(Short.toUnsignedInt(extendedHeaderBuffer.getShort()));
            entryBuilder.setUnixUserId(Short.toUnsignedInt(extendedHeaderBuffer.getShort()));
            break;
        case EXTENDED_HEADER_TYPE_UNIX_TIMESTAMP:
            // UNIX last modified time
            if (extendedHeaderLength != MIN_EXTENDED_HEADER_LENGTH + EXTENDED_HEADER_TYPE_UNIX_TIMESTAMP_PAYLOAD_LENGTH) {
                throw new ArchiveException("Invalid extended header length");
            }
            entryBuilder.setLastModifiedDate(new Date(Integer.toUnsignedLong(extendedHeaderBuffer.getInt()) * 1000));
            break;
        default:
            break;
        }
        // Ignore unknown extended header
    }

    private LhaArchiveEntry prepareDecompression(final LhaArchiveEntry entry) throws IOException {
        // Make sure we never read more than the compressed size of the entry
        this.currentCompressedStream = BoundedInputStream.builder().setInputStream(in).setMaxCount(entry.getCompressedSize()).get();
        if (isDirectory(entry.getCompressionMethod())) {
            // Directory entry
            this.currentDecompressedStream = new ByteArrayInputStream(new byte[0]);
        } else if (COMPRESSION_METHOD_LH0.equals(entry.getCompressionMethod()) || COMPRESSION_METHOD_LZ4.equals(entry.getCompressionMethod())) {
            // No compression
            this.currentDecompressedStream = ChecksumInputStream.builder().setChecksum(Crc16.arc()).setExpectedChecksumValue(entry.getCrcValue())
                    .setInputStream(this.currentCompressedStream).get();
        } else if (COMPRESSION_METHOD_LH4.equals(entry.getCompressionMethod())) {
            this.currentDecompressedStream = ChecksumInputStream.builder().setChecksum(Crc16.arc()).setExpectedChecksumValue(entry.getCrcValue())
                    .setInputStream(LhStaticHuffmanCompressorInputStream.lh4CompressorInputStream(this.currentCompressedStream)).get();
        } else if (COMPRESSION_METHOD_LH5.equals(entry.getCompressionMethod())) {
            this.currentDecompressedStream = ChecksumInputStream.builder().setChecksum(Crc16.arc()).setExpectedChecksumValue(entry.getCrcValue())
                    .setInputStream(LhStaticHuffmanCompressorInputStream.lh5CompressorInputStream(this.currentCompressedStream)).get();
        } else if (COMPRESSION_METHOD_LH6.equals(entry.getCompressionMethod())) {
            this.currentDecompressedStream = ChecksumInputStream.builder().setChecksum(Crc16.arc()).setExpectedChecksumValue(entry.getCrcValue())
                    .setInputStream(LhStaticHuffmanCompressorInputStream.lh6CompressorInputStream(this.currentCompressedStream)).get();
        } else if (COMPRESSION_METHOD_LH7.equals(entry.getCompressionMethod())) {
            this.currentDecompressedStream = ChecksumInputStream.builder().setChecksum(Crc16.arc()).setExpectedChecksumValue(entry.getCrcValue())
                    .setInputStream(LhStaticHuffmanCompressorInputStream.lh7CompressorInputStream(this.currentCompressedStream)).get();
        } else {
            // Unsupported compression
            this.currentDecompressedStream = null;
        }
        return entry;
    }

    @Override
    public int read(final byte[] buffer, final int offset, final int length) throws IOException {
        if (currentEntry == null) {
            throw new IllegalStateException("No current entry");
        }
        if (currentDecompressedStream == null) {
            throw new ArchiveException("Unsupported compression method: %s", currentEntry.getCompressionMethod());
        }
        final int read = currentDecompressedStream.read(buffer, offset, length);
        count(read);
        return read;
    }

    /**
     * Read extended header from the input stream.
     *
     * @param headerSize the size of the extended header to read.
     * @return the extended header as a ByteBuffer.
     * @throws IOException Thrown if a read error occurs.
     */
    private ByteBuffer readExtendedHeader(final int headerSize) throws IOException {
        final byte[] extensionHeader = new byte[headerSize];
        final int len = IOUtils.read(in, extensionHeader);
        if (len != extensionHeader.length) {
            throw new ArchiveException("Error reading extended header");
        }
        return ByteBuffer.wrap(extensionHeader).order(ByteOrder.LITTLE_ENDIAN);
    }

    /**
     * Read the next LHA header from the input stream.
     *
     * @return the next header entry, or null if there are no more entries.
     * @throws IOException Thrown if a read error occurs.
     */
    private LhaArchiveEntry readHeader() throws IOException {
        // Header level is not known yet. Read the minimum length header.
        final byte[] buffer = new byte[HEADER_GENERIC_MINIMUM_HEADER_LENGTH];
        final int len = IOUtils.read(in, buffer);
        if (len == 0 || len == 1 && buffer[0] == 0) {
            // Last byte of the file is zero indicating no more entries
            return null;
        }
        if (len < HEADER_GENERIC_MINIMUM_HEADER_LENGTH) {
            throw new ArchiveException("Invalid header length");
        }
        final ByteBuffer header = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN);
        // Determine header level
        final byte headerLevel = header.get(HEADER_GENERIC_OFFSET_HEADER_LEVEL);
        switch (headerLevel) {
        case 0:
            return readHeaderLevel0(header);
        case 1:
            return readHeaderLevel1(header);
        case 2:
            return readHeaderLevel2(header);
        default:
            throw new ArchiveException("Invalid header level: %d", headerLevel);
        }
    }

    /**
     * Read LHA header level 0.
     *
     * @param buffer the buffer containing the header data.
     * @return the LhaArchiveEntry read from the buffer.
     * @throws IOException Thrown if a read error occurs.
     */
    private LhaArchiveEntry readHeaderLevel0(ByteBuffer buffer) throws IOException {
        // Add two to the header size as the first two bytes are not included
        final int headerSize = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_0_OFFSET_HEADER_SIZE)) + 2;
        if (headerSize < HEADER_GENERIC_MINIMUM_HEADER_LENGTH) {
            throw new ArchiveException("Invalid header level 0 length: %d", headerSize);
        }
        buffer = readRemainingHeaderData(buffer, headerSize);
        final int headerChecksum = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_0_OFFSET_HEADER_CHECKSUM));
        final String compressionMethod = getCompressionMethod(buffer);
        // @formatter:off
        final LhaArchiveEntry.Builder entryBuilder = new LhaArchiveEntry.Builder()
                .setCompressionMethod(compressionMethod)
                .setCompressedSize(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_0_OFFSET_COMPRESSED_SIZE)))
                .setSize(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_0_OFFSET_ORIGINAL_SIZE)))
                .setLastModifiedDate(new Date(ZipUtil.dosToJavaTime(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_0_OFFSET_LAST_MODIFIED_DATE_TIME)))));
        // @formatter:on
        final int filenameLength = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_0_OFFSET_FILENAME_LENGTH));
        // Make sure the filename is not overflowing into the CRC field
        if (filenameLength > headerSize - HEADER_LEVEL_0_OFFSET_FILENAME - 2) {
            throw new ArchiveException("Invalid pathname length");
        }
        buffer.position(HEADER_LEVEL_0_OFFSET_FILENAME);
        // @formatter:off
        entryBuilder
            .setFileName(getPathname(buffer, filenameLength))
            .setDirectory(isDirectory(compressionMethod))
            .setCrc(Short.toUnsignedInt(buffer.getShort()));
        // @formatter:on
        if (calculateHeaderChecksum(buffer) != headerChecksum) {
            throw new ArchiveException("Invalid header level 0 checksum");
        }
        return prepareDecompression(entryBuilder.get());
    }

    /**
     * Read LHA header level 1.
     *
     * @param buffer the buffer containing the header data
     * @return the LhaArchiveEntry read from the buffer
     * @throws IOException Thrown if a read error occurs.
     */
    private LhaArchiveEntry readHeaderLevel1(ByteBuffer buffer) throws IOException {
        // Add two to the header size as the first two bytes are not included
        final int baseHeaderSize = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_1_OFFSET_BASE_HEADER_SIZE)) + 2;
        if (baseHeaderSize < HEADER_GENERIC_MINIMUM_HEADER_LENGTH) {
            throw new ArchiveException("Invalid header level 1 length: %d", baseHeaderSize);
        }
        buffer = readRemainingHeaderData(buffer, baseHeaderSize);
        final int baseHeaderChecksum = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_1_OFFSET_BASE_HEADER_CHECKSUM));
        final String compressionMethod = getCompressionMethod(buffer);
        long skipSize = Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_1_OFFSET_SKIP_SIZE));
        // @formatter:off
        final LhaArchiveEntry.Builder entryBuilder = new LhaArchiveEntry.Builder()
                .setCompressionMethod(compressionMethod)
                .setSize(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_1_OFFSET_ORIGINAL_SIZE)))
                .setLastModifiedDate(new Date(ZipUtil.dosToJavaTime(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_1_OFFSET_LAST_MODIFIED_DATE_TIME)))));
        // @formatter:on
        final int filenameLength = Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_1_OFFSET_FILENAME_LENGTH));
        // Make sure the filename is not overflowing into the CRC, OS ID and first extended header length fields.
        // This check is not bulletproof because there might also be an extended area after the filename that
        // we cannot detect for corrupt archives.
        if (filenameLength > baseHeaderSize - HEADER_LEVEL_1_OFFSET_FILENAME - 5) {
            throw new ArchiveException("Invalid pathname length");
        }
        buffer.position(HEADER_LEVEL_1_OFFSET_FILENAME);
        // @formatter:off
        entryBuilder
            .setFileName(getPathname(buffer, filenameLength))
            .setDirectory(isDirectory(compressionMethod))
            .setCrc(Short.toUnsignedInt(buffer.getShort()))
            .setOsId(Byte.toUnsignedInt(buffer.get()));
        // @formatter:on
        if (calculateHeaderChecksum(buffer) != baseHeaderChecksum) {
            throw new ArchiveException("Invalid header level 1 checksum");
        }
        // Create a list to store base header and all extended headers
        // to be able to calculate the CRC of the full header
        final List<ByteBuffer> headerParts = new ArrayList<>();
        headerParts.add(buffer);
        buffer.position(baseHeaderSize - 2); // First extended header length is at the end of the base header
        int extendedHeaderSize = Short.toUnsignedInt(buffer.getShort());
        while (extendedHeaderSize > 0) {
            final ByteBuffer extendedHeaderBuffer = readExtendedHeader(extendedHeaderSize);
            skipSize -= extendedHeaderSize;
            parseExtendedHeader(extendedHeaderBuffer, entryBuilder);
            headerParts.add(extendedHeaderBuffer);
            extendedHeaderSize = Short.toUnsignedInt(extendedHeaderBuffer.getShort(extendedHeaderBuffer.limit() - 2));
        }
        // The compressed size is derived by subtracting the extended header sizes from the skip size. A
        // corrupt archive whose extended headers exceed the skip size would yield a negative compressed
        // size, which must be rejected as it would otherwise disable the per-entry read bound.
        if (skipSize < 0) {
            throw new ArchiveException("Invalid compressed size");
        }
        entryBuilder.setCompressedSize(skipSize);
        final LhaArchiveEntry entry = entryBuilder.get();
        if (entry.getHeaderCrc() != null) {
            // Calculate CRC16 of full header
            final long headerCrc = calculateCRC16(headerParts.toArray(new ByteBuffer[headerParts.size()]));
            if (headerCrc != entry.getHeaderCrc()) {
                throw new ArchiveException("Invalid header CRC expected=0x%04x found=0x%04x", headerCrc, entry.getHeaderCrc());
            }
        }
        return prepareDecompression(entry);
    }

    /**
     * Read LHA header level 2.
     *
     * @param buffer the buffer containing the header data.
     * @return the LhaArchiveEntry read from the buffer.
     * @throws IOException Thrown if a read error occurs.
     */
    private LhaArchiveEntry readHeaderLevel2(ByteBuffer buffer) throws IOException {
        final int headerSize = Short.toUnsignedInt(buffer.getShort(HEADER_LEVEL_2_OFFSET_HEADER_SIZE));
        if (headerSize < HEADER_LEVEL_2_MINIMUM_HEADER_LENGTH) {
            throw new ArchiveException("Invalid header level 2 length: %d", headerSize);
        }
        buffer = readRemainingHeaderData(buffer, headerSize);
        final String compressionMethod = getCompressionMethod(buffer);
        // @formatter:off
        final LhaArchiveEntry.Builder entryBuilder = new LhaArchiveEntry.Builder()
                .setCompressionMethod(compressionMethod)
                .setCompressedSize(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_2_OFFSET_COMPRESSED_SIZE)))
                .setSize(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_2_OFFSET_ORIGINAL_SIZE)))
                .setLastModifiedDate(new Date(Integer.toUnsignedLong(buffer.getInt(HEADER_LEVEL_2_OFFSET_LAST_MODIFIED_DATE_TIME)) * 1000))
                .setDirectory(isDirectory(compressionMethod))
                .setCrc(Short.toUnsignedInt(buffer.getShort(HEADER_LEVEL_2_OFFSET_CRC)))
                .setOsId(Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_2_OFFSET_OS_ID)));
        // @formatter:on
        int extendedHeaderSize = Short.toUnsignedInt(buffer.getShort(HEADER_LEVEL_2_OFFSET_FIRST_EXTENDED_HEADER_SIZE));
        int extendedHeaderOffset = HEADER_LEVEL_2_OFFSET_FIRST_EXTENDED_HEADER_SIZE + 2;
        while (extendedHeaderSize > 0) {
            if (extendedHeaderOffset + extendedHeaderSize > buffer.limit()) {
                throw new ArchiveException("Invalid extended header length");
            }
            // Create new ByteBuffer as a slice from the full header. Set limit to the extended header length.
            final ByteBuffer extendedHeaderBuffer = byteBufferSlice(buffer, extendedHeaderOffset, extendedHeaderSize).order(ByteOrder.LITTLE_ENDIAN);
            extendedHeaderOffset += extendedHeaderSize;
            parseExtendedHeader(extendedHeaderBuffer, entryBuilder);
            extendedHeaderSize = Short.toUnsignedInt(extendedHeaderBuffer.getShort(extendedHeaderBuffer.limit() - 2));
        }
        final LhaArchiveEntry entry = entryBuilder.get();
        if (entry.getHeaderCrc() != null) {
            // Calculate CRC16 of full header
            final long headerCrc = calculateCRC16(buffer);
            if (headerCrc != entry.getHeaderCrc()) {
                throw new ArchiveException("Invalid header CRC expected=0x%04x found=0x%04x", headerCrc, entry.getHeaderCrc());
            }
        }
        return prepareDecompression(entry);
    }

    /**
     * Read the remaining part of the header and append it to the already loaded parts.
     *
     * @param currentHeader All header parts that have already been loaded into memory.
     * @param headerSize    Total header size.
     * @return header The complete header as a ByteBuffer.
     * @throws IOException Thrown if a read error occurs.
     */
    private ByteBuffer readRemainingHeaderData(final ByteBuffer currentHeader, final int headerSize) throws IOException {
        final byte[] remainingData = new byte[headerSize - currentHeader.capacity()];
        final int len = IOUtils.read(in, remainingData);
        if (len != remainingData.length) {
            throw new ArchiveException("Error reading remaining header");
        }
        return ByteBuffer.allocate(currentHeader.capacity() + len).put(currentHeader.array()).put(remainingData).order(ByteOrder.LITTLE_ENDIAN);
    }
}