diff --git a/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamFactory.java b/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamFactory.java index 2c33d769d91..94c8fbeab0b 100644 --- a/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamFactory.java +++ b/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamFactory.java @@ -37,6 +37,7 @@ import org.apache.commons.compress.archivers.dump.DumpArchiveInputStream; import org.apache.commons.compress.archivers.jar.JarArchiveInputStream; import org.apache.commons.compress.archivers.jar.JarArchiveOutputStream; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; import org.apache.commons.compress.archivers.sevenz.SevenZFile; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; @@ -90,6 +91,8 @@ public class ArchiveStreamFactory implements ArchiveStreamProvider { private static final int DUMP_SIGNATURE_SIZE = 32; + private static final int LHA_SIGNATURE_SIZE = 22; + private static final int SIGNATURE_SIZE = 12; /** @@ -174,6 +177,13 @@ public class ArchiveStreamFactory implements ArchiveStreamProvider { */ public static final String JAR = "jar"; + /** + * Constant (value {@value}) used to identify the LHA archive format. + * Not supported as an output stream type. + * @since 1.29.0 + */ + public static final String LHA = "lha"; + /** * Constant used to identify the TAR archive format. * @@ -255,6 +265,18 @@ public static String detect(final InputStream in) throws ArchiveException { if (DumpArchiveInputStream.matches(dumpsig, signatureLength)) { return DUMP; } + // LHA needs a bigger buffer to check the signature + final byte[] lhasig = new byte[LHA_SIGNATURE_SIZE]; + in.mark(lhasig.length); + try { + signatureLength = IOUtils.readFully(in, lhasig); + in.reset(); + } catch (final IOException e) { + throw new ArchiveException("IOException while reading LHA signature", (Throwable) e); + } + if (LhaArchiveInputStream.matches(lhasig, signatureLength)) { + return LHA; + } // Tar needs an even bigger buffer to check the signature; read the first block final byte[] tarHeader = new byte[TAR_HEADER_SIZE]; in.mark(tarHeader.length); @@ -439,6 +461,13 @@ public > I createArchiveInp } return (I) arjBuilder.get(); } + if (LHA.equalsIgnoreCase(archiverName)) { + final LhaArchiveInputStream.Builder lhaBuilder = LhaArchiveInputStream.builder().setInputStream(in); + if (actualEncoding != null) { + lhaBuilder.setCharset(actualEncoding); + } + return (I) lhaBuilder.get(); + } if (ZIP.equalsIgnoreCase(archiverName)) { final ZipArchiveInputStream.Builder zipBuilder = ZipArchiveInputStream.builder().setInputStream(in); if (actualEncoding != null) { @@ -593,7 +622,7 @@ public String getEntryEncoding() { @Override public Set getInputStreamArchiveNames() { - return Sets.newHashSet(AR, ARJ, ZIP, TAR, JAR, CPIO, DUMP, SEVEN_Z); + return Sets.newHashSet(AR, ARJ, LHA, ZIP, TAR, JAR, CPIO, DUMP, SEVEN_Z); } @Override diff --git a/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamProvider.java b/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamProvider.java index 7c9d93888b8..4eb596eb10c 100644 --- a/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamProvider.java +++ b/src/main/java/org/apache/commons/compress/archivers/ArchiveStreamProvider.java @@ -36,6 +36,7 @@ public interface ArchiveStreamProvider { * @param The {@link ArchiveInputStream} type. * @param archiverName the archiver name, i.e. {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#AR}, * {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#ARJ}, + * {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#LHA}, * {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#ZIP}, * {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#TAR}, * {@value org.apache.commons.compress.archivers.ArchiveStreamFactory#JAR}, diff --git a/src/main/java/org/apache/commons/compress/archivers/lha/CRC16.java b/src/main/java/org/apache/commons/compress/archivers/lha/CRC16.java new file mode 100644 index 00000000000..bcee1d25b5d --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/lha/CRC16.java @@ -0,0 +1,94 @@ +/* + * 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.util.zip.Checksum; + +/** + * CRC-16 checksum implementation based on polynomial x^16 + x^15 + x^2 + 1 (0x8005) and + * the initial value 0x0000. This CRC variant is also known as CRC-16-MODBUS, CRC-16-IBM + * and CRC-16-ANSI. + */ +class CRC16 implements Checksum { + private static final int INITIAL_VALUE = 0x0000; + + private static final int[] CRC16_TABLE = { + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 + }; + + private int crc; + + CRC16() { + reset(); + } + + @Override + public long getValue() { + return this.crc; + } + + @Override + public void reset() { + this.crc = INITIAL_VALUE; + } + + @Override + public void update(final int b) { + this.crc = (this.crc >>> 8) ^ CRC16_TABLE[(this.crc ^ b) & 0xff]; + } + + @Override + public void update(final byte[] b, final int off, final int len) { + for (int i = 0; i < len; i++) { + update(b[off + i]); + } + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntry.java b/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntry.java new file mode 100644 index 00000000000..6aea79e31ac --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntry.java @@ -0,0 +1,314 @@ +/* + * 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.time.ZoneOffset; +import java.util.Date; + +import org.apache.commons.compress.archivers.ArchiveEntry; + +/** + * Represents an entry in a LHA archive. + * + * @since 1.29.0 + */ +public class LhaArchiveEntry implements ArchiveEntry { + private final String name; + private final boolean directory; + private final long size; + private final Date lastModifiedDate; + private final long compressedSize; + private final String compressionMethod; + private final int crcValue; + private final Integer osId; + private final Integer unixPermissionMode; + private final Integer unixUserId; + private final Integer unixGroupId; + private final Integer msdosFileAttributes; + private final Integer headerCrc; + + LhaArchiveEntry(String name, boolean directory, long size, Date lastModifiedDate, + long compressedSize, String compressionMethod, int crcValue, Integer osId, + Integer unixPermissionMode, Integer unixUserId, Integer unixGroupId, + Integer msdosFileAttributes, Integer headerCrc) { + this.name = name; + this.directory = directory; + this.size = size; + this.lastModifiedDate = lastModifiedDate; + this.compressedSize = compressedSize; + this.compressionMethod = compressionMethod; + this.crcValue = crcValue; + this.osId = osId; + this.unixPermissionMode = unixPermissionMode; + this.unixUserId = unixUserId; + this.unixGroupId = unixGroupId; + this.msdosFileAttributes = msdosFileAttributes; + this.headerCrc = headerCrc; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer().append("LhaArchiveEntry[") + .append("name=").append(name) + .append(",directory=").append(directory) + .append(",size=").append(size) + .append(",lastModifiedDate=").append(lastModifiedDate == null ? "" : lastModifiedDate.toInstant().atZone(ZoneOffset.UTC).toString()) + .append(",compressedSize=").append(compressedSize) + .append(",compressionMethod=").append(compressionMethod) + .append(",crcValue=").append(String.format("0x%04x", crcValue)); + + if (osId != null) { + sb.append(",osId=").append(osId); + } + + if (unixPermissionMode != null) { + sb.append(",unixPermissionMode=").append(String.format("%03o", unixPermissionMode)); + } + + if (msdosFileAttributes != null) { + sb.append(",msdosFileAttributes=").append(String.format("%04x", msdosFileAttributes)); + } + + if (headerCrc != null) { + sb.append(",headerCrc=").append(String.format("0x%04x", headerCrc)); + } + + return sb.append("]").toString(); + } + + static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return name; + } + + @Override + public long getSize() { + return size; + } + + @Override + public Date getLastModifiedDate() { + return lastModifiedDate; + } + + /** + * Gets the compressed size of this entry. + * + * @return the compressed size + */ + public long getCompressedSize() { + return compressedSize; + } + + @Override + public boolean isDirectory() { + return directory; + } + + /** + * Gets the compression method of this entry. + * + * @return the compression method + */ + public String getCompressionMethod() { + return compressionMethod; + } + + /** + * Gets the CRC-16 checksum of the uncompressed data of this entry. + * + * @return CRC-16 checksum of the uncompressed data + */ + public int getCrcValue() { + return crcValue; + } + + /** + * Gets the operating system id if available for this entry. + * + * @return operating system id or null if not available + */ + public Integer getOsId() { + return osId; + } + + /** + * Gets the Unix permission mode if available for this entry. + * + * @return Unix permission mode or null if not available + */ + public Integer getUnixPermissionMode() { + return unixPermissionMode; + } + + /** + * Gets the Unix user id if available for this entry. + * + * @return Unix user id or null if not available + */ + public Integer getUnixUserId() { + return unixUserId; + } + + /** + * Gets the Unix group id if available for this entry. + * + * @return Unix group id or null if not available + */ + public Integer getUnixGroupId() { + return unixGroupId; + } + + /** + * Gets the MS-DOS file attributes if available for this entry. + * + * @return MS-DOS file attributes or null if not available + */ + public Integer getMsdosFileAttributes() { + return msdosFileAttributes; + } + + /** + * Gets the header CRC if available for this entry. + * + * This method is package private, as it is of no interest to most users. + * + * @return header CRC or null if not available + */ + Integer getHeaderCrc() { + return headerCrc; + } + + static class Builder { + private String filename; + private String directoryName; + private boolean directory; + private long size; + private Date lastModifiedDate; + private long compressedSize; + private String compressionMethod; + private int crcValue; + private Integer osId; + private Integer unixPermissionMode; + private Integer unixUserId; + private Integer unixGroupId; + private Integer msdosFileAttributes; + private Integer headerCrc; + + Builder() { + } + + LhaArchiveEntry get() { + final String name = new StringBuilder() + .append(directoryName == null ? "" : directoryName) + .append(filename == null ? "" : filename) + .toString(); + + return new LhaArchiveEntry( + name, + directory, + size, + lastModifiedDate, + compressedSize, + compressionMethod, + crcValue, + osId, + unixPermissionMode, + unixUserId, + unixGroupId, + msdosFileAttributes, + headerCrc); + } + + Builder setFilename(String filenName) { + this.filename = filenName; + return this; + } + + Builder setDirectoryName(String directoryName) { + this.directoryName = directoryName; + return this; + } + + Builder setDirectory(boolean directory) { + this.directory = directory; + return this; + } + + Builder setSize(long size) { + this.size = size; + return this; + } + + Builder setLastModifiedDate(Date lastModifiedDate) { + this.lastModifiedDate = lastModifiedDate; + return this; + } + + Builder setCompressedSize(long compressedSize) { + this.compressedSize = compressedSize; + return this; + } + + Builder setCompressionMethod(String compressionMethod) { + this.compressionMethod = compressionMethod; + return this; + } + + Builder setCrcValue(int crcValue) { + this.crcValue = crcValue; + return this; + } + + Builder setOsId(Integer osId) { + this.osId = osId; + return this; + } + + Builder setUnixPermissionMode(Integer unixPermissionMode) { + this.unixPermissionMode = unixPermissionMode; + return this; + } + + Builder setUnixUserId(Integer unixUserId) { + this.unixUserId = unixUserId; + return this; + } + + Builder setUnixGroupId(Integer unixGroupId) { + this.unixGroupId = unixGroupId; + return this; + } + + Builder setMsdosFileAttributes(Integer msdosFileAttributes) { + this.msdosFileAttributes = msdosFileAttributes; + return this; + } + + Builder setHeaderCrc(Integer headerCrc) { + this.headerCrc = headerCrc; + return this; + } + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStream.java new file mode 100644 index 00000000000..66564928213 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStream.java @@ -0,0 +1,805 @@ +/* + * 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 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.compress.compressors.lha.Lh4CompressorInputStream; +import org.apache.commons.compress.compressors.lha.Lh5CompressorInputStream; +import org.apache.commons.compress.compressors.lha.Lh6CompressorInputStream; +import org.apache.commons.compress.compressors.lha.Lh7CompressorInputStream; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.commons.io.input.ChecksumInputStream; + +/** + * Implements the LHA archive format as an InputStream. + * + * This implementation is based on the documentation that can be found at + * http://dangan.g.dgdg.jp/en/Content/Program/Java/jLHA/Notes/Notes.html + * + * @NotThreadSafe + * @since 1.29.0 + */ +public class LhaArchiveInputStream extends ArchiveInputStream { + + /** + * Builds a new {@link LhaArchiveInputStream}. + *

+ * For example: + *

+ *
{@code
+     * LhaArchiveInputStream in = LhaArchiveInputStream.builder()
+     *   .setPath(inputPath)
+     *   .setCharset(StandardCharsets.UTF_8)
+     *   .get();
+     * }
+ */ + public static class Builder extends AbstractBuilder { + + /** + * 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. + */ + @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; + + private final char fileSeparatorChar; + private LhaArchiveEntry currentEntry; + private InputStream currentCompressedStream; + private InputStream currentDecompressedStream; + + /** + * Creates a new builder. + * + * @return a new builder. + */ + public static Builder builder() { + return new Builder(); + } + + private LhaArchiveInputStream(final Builder builder) throws IOException { + super(builder.getInputStream(), builder.getCharset()); + this.fileSeparatorChar = builder.fileSeparatorChar; + } + + @Override + public boolean canReadEntryData(final ArchiveEntry archiveEntry) { + return currentDecompressedStream != null; + } + + @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()); + } + + return currentDecompressedStream.read(buffer, offset, length); + } + + /** + * 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. Expected value is in the range 0-3. + final byte headerLevel = header.get(HEADER_GENERIC_OFFSET_HEADER_LEVEL); + if (headerLevel < 0 || headerLevel > 3) { + return false; + } + + // Check if the compression method is valid for LHA archives + try { + getCompressionMethod(header); + } catch (ArchiveException e) { + return false; + } + + return true; + } + + @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; + } + + /** + * Read the next LHA header from the input stream. + * + * @return the next header entry, or null if there are no more entries + * @throws IOException + */ + 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) { + // EOF + return null; + } else if (len == 1 && buffer[0] == 0) { + // Last byte of the file is zero indicating no more entries + return null; + } else 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); + if (headerLevel == 0) { + return readHeaderLevel0(header); + } else if (headerLevel == 1) { + return readHeaderLevel1(header); + } else if (headerLevel == 2) { + return readHeaderLevel2(header); + } else { + 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 + */ + 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); + + 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))))); + + 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); + entryBuilder.setFilename(getPathname(buffer, filenameLength)) + .setDirectory(isDirectory(compressionMethod)) + .setCrcValue(Short.toUnsignedInt(buffer.getShort())); + + if (calculateHeaderChecksum(buffer) != headerChecksum) { + throw new ArchiveException("Invalid header level 0 checksum"); + } + + final LhaArchiveEntry entry = entryBuilder.get(); + + prepareDecompression(entry); + + return entry; + } + + /** + * Read LHA header level 1. + * + * @param buffer the buffer containing the header data + * @return the LhaArchiveEntry read from the buffer + * @throws IOException + */ + 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)); + + 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))))); + + 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); + entryBuilder.setFilename(getPathname(buffer, filenameLength)) + .setDirectory(isDirectory(compressionMethod)) + .setCrcValue(Short.toUnsignedInt(buffer.getShort())) + .setOsId(Byte.toUnsignedInt(buffer.get())); + + 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 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)); + } + + 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()); + } + } + + prepareDecompression(entry); + + return entry; + } + + /** + * Read LHA header level 2. + * + * @param buffer the buffer containing the header data + * @return the LhaArchiveEntry read from the buffer + * @throws IOException + */ + 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); + + 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)) + .setCrcValue(Short.toUnsignedInt(buffer.getShort(HEADER_LEVEL_2_OFFSET_CRC))) + .setOsId(Byte.toUnsignedInt(buffer.get(HEADER_LEVEL_2_OFFSET_OS_ID))); + + 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()); + } + } + + prepareDecompression(entry); + + return entry; + } + + /** + * 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); + } else { + 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]); + } + } + + /** + * 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); + } else if (pathnameLength < 0) { + throw new ArchiveException("Pathname length is negative"); + } else 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; + } + + /** + * 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 + */ + 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); + } + + /** + * 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 + */ + 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); + } + + /** + * 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 IOException + */ + void parseExtendedHeader(final ByteBuffer extendedHeaderBuffer, final LhaArchiveEntry.Builder entryBuilder) throws IOException { + 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()); + if (extendedHeaderType == 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); + } else if (extendedHeaderType == 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); + } else if (extendedHeaderType == 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); + } + + } else if (extendedHeaderType == 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())); + } else if (extendedHeaderType == 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())); + } else if (extendedHeaderType == 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())); + } else if (extendedHeaderType == 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)); + } + + // Ignore unknown extended header + } + + /** + * 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); + } + + /** + * 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; + } + + /** + * 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 CRC16 crc = new CRC16(); + for (ByteBuffer buffer : buffers) { + crc.update(buffer.array(), 0, buffer.limit()); + } + + return crc.getValue(); + } + + private void 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(new CRC16()) + .setExpectedChecksumValue(entry.getCrcValue()) + .setInputStream(this.currentCompressedStream) + .get(); + } else if (COMPRESSION_METHOD_LH4.equals(entry.getCompressionMethod())) { + this.currentDecompressedStream = ChecksumInputStream.builder() + .setChecksum(new CRC16()) + .setExpectedChecksumValue(entry.getCrcValue()) + .setInputStream(new Lh4CompressorInputStream(this.currentCompressedStream)) + .get(); + } else if (COMPRESSION_METHOD_LH5.equals(entry.getCompressionMethod())) { + this.currentDecompressedStream = ChecksumInputStream.builder() + .setChecksum(new CRC16()) + .setExpectedChecksumValue(entry.getCrcValue()) + .setInputStream(new Lh5CompressorInputStream(this.currentCompressedStream)) + .get(); + } else if (COMPRESSION_METHOD_LH6.equals(entry.getCompressionMethod())) { + this.currentDecompressedStream = ChecksumInputStream.builder() + .setChecksum(new CRC16()) + .setExpectedChecksumValue(entry.getCrcValue()) + .setInputStream(new Lh6CompressorInputStream(this.currentCompressedStream)) + .get(); + } else if (COMPRESSION_METHOD_LH7.equals(entry.getCompressionMethod())) { + this.currentDecompressedStream = ChecksumInputStream.builder() + .setChecksum(new CRC16()) + .setExpectedChecksumValue(entry.getCrcValue()) + .setInputStream(new Lh7CompressorInputStream(this.currentCompressedStream)) + .get(); + } else { + // Unsupported compression + this.currentDecompressedStream = null; + } + } + + /** + * 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); + } + + /** + * Get 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); + } + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/lha/package-info.java b/src/main/java/org/apache/commons/compress/archivers/lha/package-info.java new file mode 100644 index 00000000000..6ce4194a9fd --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/lha/package-info.java @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Provides stream classes for reading archives using the LHA format, + * also known as the LZH format or LHarc format. + */ +package org.apache.commons.compress.archivers.lha; diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ExplodingInputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/ExplodingInputStream.java index b03798ab76d..08f98fb7852 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ExplodingInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ExplodingInputStream.java @@ -23,6 +23,7 @@ import java.io.InputStream; import org.apache.commons.compress.archivers.ArchiveException; +import org.apache.commons.compress.utils.CircularBuffer; import org.apache.commons.compress.utils.InputStreamStatistics; import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.input.CloseShieldInputStream; diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStream.java new file mode 100644 index 00000000000..4e36a588860 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStream.java @@ -0,0 +1,389 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteOrder; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.compressors.CompressorInputStream; +import org.apache.commons.compress.utils.BitInputStream; +import org.apache.commons.compress.utils.CircularBuffer; +import org.apache.commons.compress.utils.InputStreamStatistics; +import org.apache.commons.io.input.CloseShieldInputStream; + +/** + * This is an implementation of a static Huffman compressor input stream for LHA files that + * supports lh4, lh5, lh6 and lh7 compression methods. + */ +abstract class AbstractLhStaticHuffmanCompressorInputStream extends CompressorInputStream implements InputStreamStatistics { + /** + * Number of bits used to encode the command decoding tree length. + */ + private static final int COMMAND_DECODING_LENGTH_BITS = 5; + /** + * Maximum number of codes in the command decoding tree. + */ + private static final int MAX_NUMBER_OF_COMMAND_DECODING_CODE_LENGTHS = 19; + /** + * Number of bits used to encode the command tree length. + */ + private static final int COMMAND_TREE_LENGTH_BITS = 9; + /** + * Number of literal codes (0-255). + */ + private static final int NUMBER_OF_LITERAL_CODES = 0x100; + /** + * Number of bits used to encode the code length. + */ + private static final int CODE_LENGTH_BITS = 3; + private static final int MAX_CODE_LENGTH = 16; + + private BitInputStream bin; + private CircularBuffer buffer; + private int blockSize; + /** + * Command is either a literal or a copy command. + */ + private BinaryTree commandTree; + /** + * Distance is the offset to copy from the sliding dictionary. + */ + private BinaryTree distanceTree; + + /** + * Constructs a new CompressorInputStream which decompresses bytes read from the specified stream. + * + * @param in the InputStream from which to read compressed data + * @throws IOException if an I/O error occurs + */ + AbstractLhStaticHuffmanCompressorInputStream(final InputStream in) throws IOException { + this.bin = new BitInputStream(in == System.in ? CloseShieldInputStream.wrap(in) : in, ByteOrder.BIG_ENDIAN); + + // Create a sliding dictionary buffer that can hold the full dictionary size and the maximum match length + this.buffer = new CircularBuffer(getDictionarySize() + getMaxMatchLength()); + } + + @Override + public void close() throws IOException { + if (this.bin != null) { + try { + this.bin.close(); + } finally { + this.bin = null; + this.buffer = null; + this.blockSize = -1; + } + } + } + + /** + * Gets the threshold for copying data from the sliding dictionary. This is the minimum + * possible number of bytes that will be part of a copy command. + * + * @return the copy threshold + */ + int getCopyThreshold() { + return 3; + } + + /** + * Gets the number of bits used for the dictionary size. + * + * @return the number of bits used for the dictionary size + */ + abstract int getDictionaryBits(); + + /** + * Gets the size of the dictionary. + * + * @return the size of the dictionary + */ + int getDictionarySize() { + return 1 << getDictionaryBits(); + } + + /** + * Gets the number of bits used for the distance. + * + * @return the number of bits used for the distance + */ + abstract int getDistanceBits(); + + /** + * Gets the maximum number of distance codes in the distance tree. + * + * @return the maximum number of distance codes + */ + int getMaxNumberOfDistanceCodes() { + return getDictionaryBits() + 1; + } + + /** + * Gets the maximum match length for the copy command. + * + * @return the maximum match length + */ + int getMaxMatchLength() { + return 256; + } + + /** + * Gets the maximum number of commands in the command tree. + * This is 256 literals (0-255) and 254 copy lengths combinations (3-256). + * + * @return the maximum number of commands + */ + int getMaxNumberOfCommands() { + return NUMBER_OF_LITERAL_CODES + getMaxMatchLength() - getCopyThreshold() + 1; + } + + @Override + public long getCompressedCount() { + return bin.getBytesRead(); + } + + @Override + public int read() throws IOException { + if (!buffer.available()) { + // Nothing in the buffer, try to fill it + fillBuffer(); + } + + final int ret = buffer.get(); + count(ret < 0 ? 0 : 1); // Increment input stream statistics + return ret; + } + + /** + * Fill the sliding dictionary with more data. + * + * @throws IOException if an I/O error occurs + */ + private void fillBuffer() throws IOException { + if (this.blockSize == -1) { + // End of stream + return; + } else if (this.blockSize == 0) { + // Start to read the next block + + // Read the block size (number of commands to read) + this.blockSize = (int) bin.readBits(16); + if (this.blockSize == -1) { + // End of stream + return; + } + + final BinaryTree commandDecodingTree = readCommandDecodingTree(); + + this.commandTree = readCommandTree(commandDecodingTree); + + this.distanceTree = readDistanceTree(); + } + + this.blockSize--; + + final int command = commandTree.read(bin); + if (command == -1) { + throw new CompressorException("Unexpected end of stream"); + } else if (command < NUMBER_OF_LITERAL_CODES) { + // Literal command, just write the byte to the buffer + buffer.put(command); + } else { + // Copy command, read the distance and calculate the length from the command + final int distance = readDistance(); + final int length = command - NUMBER_OF_LITERAL_CODES + getCopyThreshold(); + + // Copy the data from the sliding dictionary and add to the buffer + buffer.copy(distance + 1, length); + } + } + + /** + * Read the command decoding tree. The command decoding tree is used when reading the command tree + * which is then actually used to decode the commands (literals or copy commands). + * + * @return the command decoding tree + * @throws IOException if an I/O error occurs + */ + BinaryTree readCommandDecodingTree() throws IOException { + // Number of code lengths to read + final int numCodeLengths = readBits(COMMAND_DECODING_LENGTH_BITS); + + if (numCodeLengths > MAX_NUMBER_OF_COMMAND_DECODING_CODE_LENGTHS) { + throw new CompressorException("Code length table has invalid size (%d > %d)", numCodeLengths, MAX_NUMBER_OF_COMMAND_DECODING_CODE_LENGTHS); + } else if (numCodeLengths == 0) { + // If numCodeLengths is zero, we read a single code length of COMMAND_DECODING_LENGTH_BITS bits and use as root of the tree + return new BinaryTree(readBits(COMMAND_DECODING_LENGTH_BITS)); + } else { + // Read all code lengths + final int[] codeLengths = new int[numCodeLengths]; + for (int index = 0; index < numCodeLengths; index++) { + codeLengths[index] = readCodeLength(); + + if (index == 2) { + // After reading the first three code lengths, we read a 2-bit skip range + index += readBits(2); + } + } + + return new BinaryTree(codeLengths); + } + } + + /** + * Read code length (depth in tree). Usually 0-7 but could be higher and if so, + * count the number of following consecutive one bits and add to the length. + * + * @return code length + * @throws IOException if an I/O error occurs + */ + int readCodeLength() throws IOException { + int len = readBits(CODE_LENGTH_BITS); + if (len == 0x07) { + int bit = bin.readBit(); + while (bit == 1) { + if (++len > MAX_CODE_LENGTH) { + throw new CompressorException("Code length overflow"); + } + + bit = bin.readBit(); + } + + if (bit == -1) { + throw new CompressorException("Unexpected end of stream"); + } + } + + return len; + } + + /** + * Read the command tree which is used to decode the commands (literals or copy commands). + * + * @param commandDecodingTree the Huffman tree used to decode the command lengths + * @return the command tree + * @throws IOException if an I/O error occurs + */ + BinaryTree readCommandTree(final BinaryTree commandDecodingTree) throws IOException { + final int numCodeLengths = readBits(COMMAND_TREE_LENGTH_BITS); + + if (numCodeLengths > getMaxNumberOfCommands()) { + throw new CompressorException("Code length table has invalid size (%d > %d)", numCodeLengths, getMaxNumberOfCommands()); + } else if (numCodeLengths == 0) { + // If numCodeLengths is zero, we read a single code length of COMMAND_TREE_LENGTH_BITS bits and use as root of the tree + return new BinaryTree(readBits(COMMAND_TREE_LENGTH_BITS)); + } else { + // Read all code lengths + final int[] codeLengths = new int[numCodeLengths]; + + for (int index = 0; index < numCodeLengths;) { + final int codeOrSkipRange = commandDecodingTree.read(bin); + + if (codeOrSkipRange == -1) { + throw new CompressorException("Unexpected end of stream"); + } else if (codeOrSkipRange == 0) { + // Skip one code length + index++; + } else if (codeOrSkipRange == 1) { + // Skip a range of code lengths, read 4 bits to determine how many to skip + index += readBits(4) + 3; + } else if (codeOrSkipRange == 2) { + // Skip a range of code lengths, read 9 bits to determine how many to skip + index += readBits(9) + 20; + } else { + // Subtract 2 from the codeOrSkipRange to get the code length + codeLengths[index++] = codeOrSkipRange - 2; + } + } + + return new BinaryTree(codeLengths); + } + } + + /** + * Read the distance tree which is used to decode the distance of the copy command. + * + * @return the distance tree + * @throws IOException if an I/O error occurs + */ + private BinaryTree readDistanceTree() throws IOException { + // Number of code lengths to read + final int numCodeLengths = readBits(getDistanceBits()); + + if (numCodeLengths > getMaxNumberOfDistanceCodes()) { + throw new CompressorException("Code length table has invalid size (%d > %d)", numCodeLengths, getMaxNumberOfDistanceCodes()); + } else if (numCodeLengths == 0) { + // If numCodeLengths is zero, we read a single code length of getDistanceBits() bits and use as root of the tree + return new BinaryTree(readBits(getDistanceBits())); + } else { + // Read all code lengths + final int[] codeLengths = new int[numCodeLengths]; + for (int index = 0; index < numCodeLengths; index++) { + codeLengths[index] = readCodeLength(); + } + + return new BinaryTree(codeLengths); + } + } + + /** + * Read the distance by first decoding the number of bits to read from the distance tree + * and then reading the actual distance value from the bit input stream. + * + * @return the distance + * @throws IOException if an I/O error occurs + */ + private int readDistance() throws IOException { + // Determine the number of bits to read for the distance by reading an entry from the distance tree + final int bits = distanceTree.read(bin); + if (bits == -1) { + throw new CompressorException("Unexpected end of stream"); + } else if (bits == 0 || bits == 1) { + // This is effectively run length encoding + return bits; + } else { + // Bits minus one is the number of bits to read for the distance + final int value = readBits(bits - 1); + + // Add the implicit bit (1 << (bits - 1)) to the value read from the stream giving the distance. + // E.g. if bits is 6, we read 5 bits giving value 8 and then we add 32 giving a distance of 40. + return value | (1 << (bits - 1)); + } + } + + /** + * Read the specified number of bits from the underlying stream throwing CompressorException + * if the end of the stream is reached before reading the requested number of bits. + * + * @param count the number of bits to read + * @return the bits concatenated as an int using the stream's byte order + * @throws IOException if an I/O error occurs. + */ + private int readBits(final int count) throws IOException { + final long value = bin.readBits(count); + if (value < 0) { + throw new CompressorException("Unexpected end of stream"); + } + + return (int) value; + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/BinaryTree.java b/src/main/java/org/apache/commons/compress/compressors/lha/BinaryTree.java new file mode 100644 index 00000000000..416b6b853dc --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/BinaryTree.java @@ -0,0 +1,159 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.util.Arrays; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.utils.BitInputStream; +import org.apache.commons.lang3.ArrayFill; + +/** + * Binary tree of positive values. + * + * Copied from org.apache.commons.compress.archivers.zip.BinaryTree and modified for LHA. + */ +class BinaryTree { + + /** Value in the array indicating an undefined node */ + private static final int UNDEFINED = -1; + + /** Value in the array indicating a non leaf node */ + private static final int NODE = -2; + + /** + * The array representing the binary tree. The root is at index 0, the left children (0) are at 2*i+1 and the right children (1) at 2*i+2. + */ + private final int[] tree; + + /** + * Constructs a binary tree from the given array that contains the depth (code length) in the + * binary tree as values in the array and the index into the array as the value of the leaf node. + * + * If the array contains a single value, this is a special case where there is only one node in the + * tree (the root node) and it contains the value. For this case, the array contains the value of + * the root node instead of the depth in the tree. This special case also means that no bits will + * be read from the bit stream when the read method is called, as there are no children to traverse. + * + * @param array the array to build the binary tree from + * @throws CompressorException if the tree is invalid + */ + BinaryTree(final int... array) throws CompressorException { + if (array.length == 1) { + // Tree only contains a single value, which is the root node value + this.tree = new int[] { array[0] }; + return; + } + + // Determine the maximum depth of the tree from the input array + final int maxDepth = Arrays.stream(array).max().getAsInt(); + if (maxDepth == 0) { + throw new CompressorException("Tree contains no leaf nodes"); + } + + // Allocate binary tree with enough space for all nodes + this.tree = initTree(maxDepth); + + int treePos = 0; + + // Add root node pointing to left (0) and right (1) children + this.tree[treePos++] = NODE; + + // Iterate over each possible tree depth (starting from 1) + for (int currentDepth = 1; currentDepth <= maxDepth; currentDepth++) { + final int startPos = (1 << currentDepth) - 1; // Start position for the first node at this depth + final int maxNodesAtCurrentDepth = 1 << currentDepth; // Max number of nodes at this depth + int numNodesAtCurrentDepth = treePos - startPos; // Number of nodes added at this depth taking into account any already skipped nodes (UNDEFINED) + + // Add leaf nodes for values with the current depth + for (int value = 0; value < array.length; value++) { + if (array[value] == currentDepth) { + if (numNodesAtCurrentDepth == maxNodesAtCurrentDepth) { + throw new CompressorException("Tree contains too many leaf nodes for depth %d", currentDepth); + } + + this.tree[treePos++] = value; // Add leaf (value) node + numNodesAtCurrentDepth++; + } + } + + // Add nodes pointing to child nodes until the maximum number of nodes at this depth is reached + int skipToTreePos = -1; + while (currentDepth != maxDepth && numNodesAtCurrentDepth < maxNodesAtCurrentDepth) { + if (skipToTreePos == -1) { + skipToTreePos = 2 * treePos + 1; // Next depth's tree position that this node's left (0) child would occupy + } + + this.tree[treePos++] = NODE; // Add node pointing to left (0) and right (1) children + numNodesAtCurrentDepth++; + } + + if (skipToTreePos != -1) { + treePos = skipToTreePos; // Skip to the next depth's tree position based on the first node at this depth + } + } + } + + /** + * Initializes the binary tree with the specified depth but with all nodes as UNDEFINED. + * + * @param depth the depth of the tree, must be between 0 and 16 (inclusive) + * @return an array representing the binary tree, initialized with UNDEFINED values + * @throws CompressorException for invalid depth + */ + private int[] initTree(final int depth) throws CompressorException { + if (depth < 0 || depth > 16) { + throw new CompressorException("Tree depth must not be negative and not bigger than 16 but is " + depth); + } + + final int arraySize = depth == 0 ? 1 : (int) ((1L << depth + 1) - 1); // Depth 0 has only a single node (the root) + + return ArrayFill.fill(new int[arraySize], UNDEFINED); + } + + /** + * Reads a value from the specified bit stream. + * + * @param stream The data source. + * @return the value decoded, or -1 if the end of the stream is reached + * @throws IOException on error. + */ + public int read(final BitInputStream stream) throws IOException { + int currentIndex = 0; + + while (true) { + final int value = tree[currentIndex]; + if (value == NODE) { + // Consume the next bit + final int bit = stream.readBit(); + if (bit == -1) { + return -1; + } + + currentIndex = 2 * currentIndex + 1 + bit; + } else if (value == UNDEFINED) { + throw new CompressorException("Invalid bitstream. The node at index %d is not defined.", currentIndex); + } else { + return value; + } + } + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStream.java new file mode 100644 index 00000000000..b5c2810bfd6 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStream.java @@ -0,0 +1,49 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Decompressor for lh4. It has a dictionary size of 4096 bytes. + * + * @since 1.29.0 + */ +public class Lh4CompressorInputStream extends AbstractLhStaticHuffmanCompressorInputStream { + public Lh4CompressorInputStream(final InputStream in) throws IOException { + super(in); + } + + @Override + int getDictionaryBits() { + return 12; + } + + @Override + int getDistanceBits() { + return 4; + } + + @Override + int getMaxNumberOfDistanceCodes() { + return getDictionaryBits() + 2; + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStream.java new file mode 100644 index 00000000000..5a1c29dab69 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStream.java @@ -0,0 +1,44 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Decompressor for lh5. It has a dictionary size of 8192 bytes. + * + * @since 1.29.0 + */ +public class Lh5CompressorInputStream extends AbstractLhStaticHuffmanCompressorInputStream { + public Lh5CompressorInputStream(final InputStream in) throws IOException { + super(in); + } + + @Override + int getDictionaryBits() { + return 13; + } + + @Override + int getDistanceBits() { + return 4; + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStream.java new file mode 100644 index 00000000000..900406eefb0 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStream.java @@ -0,0 +1,44 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Decompressor for lh6. It has a dictionary size of 32768 bytes. + * + * @since 1.29.0 + */ +public class Lh6CompressorInputStream extends AbstractLhStaticHuffmanCompressorInputStream { + public Lh6CompressorInputStream(final InputStream in) throws IOException { + super(in); + } + + @Override + int getDictionaryBits() { + return 15; + } + + @Override + int getDistanceBits() { + return 5; + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStream.java new file mode 100644 index 00000000000..eb929007df9 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStream.java @@ -0,0 +1,44 @@ +/* + * 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.compressors.lha; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Decompressor for lh7. It has a dictionary size of 65536 bytes. + * + * @since 1.29.0 + */ +public class Lh7CompressorInputStream extends AbstractLhStaticHuffmanCompressorInputStream { + public Lh7CompressorInputStream(final InputStream in) throws IOException { + super(in); + } + + @Override + int getDictionaryBits() { + return 16; + } + + @Override + int getDistanceBits() { + return 5; + } +} diff --git a/src/main/java/org/apache/commons/compress/compressors/lha/package-info.java b/src/main/java/org/apache/commons/compress/compressors/lha/package-info.java new file mode 100644 index 00000000000..8daf239f972 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/compressors/lha/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Provides stream classes for decompressing streams found in LHA archives. + */ +package org.apache.commons.compress.compressors.lha; diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/CircularBuffer.java b/src/main/java/org/apache/commons/compress/utils/CircularBuffer.java similarity index 77% rename from src/main/java/org/apache/commons/compress/archivers/zip/CircularBuffer.java rename to src/main/java/org/apache/commons/compress/utils/CircularBuffer.java index 54784052d27..0487df274e4 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/CircularBuffer.java +++ b/src/main/java/org/apache/commons/compress/utils/CircularBuffer.java @@ -17,14 +17,14 @@ * under the License. */ -package org.apache.commons.compress.archivers.zip; +package org.apache.commons.compress.utils; /** * Circular byte buffer. * - * @since 1.7 + * @since 1.29.0 */ -final class CircularBuffer { +public final class CircularBuffer { /** Size of the buffer */ private final int size; @@ -38,9 +38,12 @@ final class CircularBuffer { /** Index of the next data written in the buffer */ private int writeIndex; - CircularBuffer(final int size) { + private int bytesAvailable; + + public CircularBuffer(final int size) { this.size = size; buffer = new byte[size]; + bytesAvailable = 0; } /** @@ -49,7 +52,7 @@ final class CircularBuffer { * @return Whether a new byte can be read from the buffer. */ public boolean available() { - return readIndex != writeIndex; + return bytesAvailable > 0; } /** @@ -59,11 +62,15 @@ public boolean available() { * @param length the number of bytes to copy */ public void copy(final int distance, final int length) { + if (distance < 1) { + throw new IllegalArgumentException("Distance must be at least 1"); + } else if (distance > size) { + throw new IllegalArgumentException("Distance exceeds buffer size"); + } final int pos1 = writeIndex - distance; final int pos2 = pos1 + length; for (int i = pos1; i < pos2; i++) { - buffer[writeIndex] = buffer[(i + size) % size]; - writeIndex = (writeIndex + 1) % size; + put(buffer[(i + size) % size]); } } @@ -76,6 +83,7 @@ public int get() { if (available()) { final int value = buffer[readIndex]; readIndex = (readIndex + 1) % size; + bytesAvailable--; return value & 0xFF; } return -1; @@ -87,7 +95,11 @@ public int get() { * @param value the value to put. */ public void put(final int value) { + if (bytesAvailable == size) { + throw new IllegalStateException("Buffer overflow: Cannot write to a full buffer"); + } buffer[writeIndex] = (byte) value; writeIndex = (writeIndex + 1) % size; + bytesAvailable++; } } diff --git a/src/test/java/org/apache/commons/compress/archivers/ArchiveStreamFactoryTest.java b/src/test/java/org/apache/commons/compress/archivers/ArchiveStreamFactoryTest.java index abba17db4a1..defb2ac60a8 100644 --- a/src/test/java/org/apache/commons/compress/archivers/ArchiveStreamFactoryTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/ArchiveStreamFactoryTest.java @@ -43,6 +43,7 @@ import org.apache.commons.compress.archivers.cpio.CpioArchiveInputStream; import org.apache.commons.compress.archivers.dump.DumpArchiveInputStream; import org.apache.commons.compress.archivers.jar.JarArchiveInputStream; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; import org.apache.commons.io.input.BrokenInputStream; @@ -96,6 +97,7 @@ public String toString() { */ private static final String ARJ_DEFAULT; private static final String DUMP_DEFAULT; + private static final String LHA_DEFAULT = getCharsetName(LhaArchiveInputStream.builder().setByteArray(ArrayUtils.EMPTY_BYTE_ARRAY)); private static final String ZIP_DEFAULT = getCharsetName(new ZipArchiveInputStream(null)); private static final String CPIO_DEFAULT = getCharsetName(new CpioArchiveInputStream(null)); private static final String TAR_DEFAULT = getCharsetName(new TarArchiveInputStream(null)); @@ -138,6 +140,12 @@ public String toString() { new TestData("bla.dump", ArchiveStreamFactory.DUMP, false, StandardCharsets.UTF_8.name(), FACTORY_SET_UTF8, "charsetName"), new TestData("bla.dump", ArchiveStreamFactory.DUMP, false, StandardCharsets.US_ASCII.name(), FACTORY_SET_ASCII, "charsetName"), + new TestData("bla.lha", ArchiveStreamFactory.LHA, false, LHA_DEFAULT, FACTORY, "charsetName"), + new TestData("bla.lha", ArchiveStreamFactory.LHA, false, StandardCharsets.UTF_8.name(), FACTORY_UTF8, "charsetName"), + new TestData("bla.lha", ArchiveStreamFactory.LHA, false, StandardCharsets.US_ASCII.name(), FACTORY_ASCII, "charsetName"), + new TestData("bla.lha", ArchiveStreamFactory.LHA, false, StandardCharsets.UTF_8.name(), FACTORY_SET_UTF8, "charsetName"), + new TestData("bla.lha", ArchiveStreamFactory.LHA, false, StandardCharsets.US_ASCII.name(), FACTORY_SET_ASCII, "charsetName"), + new TestData("bla.tar", ArchiveStreamFactory.TAR, true, TAR_DEFAULT, FACTORY, "charsetName"), new TestData("bla.tar", ArchiveStreamFactory.TAR, true, StandardCharsets.UTF_8.name(), FACTORY_UTF8, "charsetName"), new TestData("bla.tar", ArchiveStreamFactory.TAR, true, StandardCharsets.US_ASCII.name(), FACTORY_ASCII, "charsetName"), @@ -160,6 +168,14 @@ private static String getCharsetName(final ArchiveInputStream inputStream) { return inputStream.getCharset().name(); } + private static String getCharsetName(final ArchiveInputStream.AbstractBuilder builder) { + try { + return builder.get().getCharset().name(); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + @SuppressWarnings("deprecation") // test of deprecated method static ArchiveStreamFactory getFactory(final String entryEncoding) { final ArchiveStreamFactory fac = new ArchiveStreamFactory(); @@ -260,7 +276,7 @@ void testDetect() throws Exception { for (final String extension : new String[] { ArchiveStreamFactory.AR, ArchiveStreamFactory.ARJ, ArchiveStreamFactory.CPIO, ArchiveStreamFactory.DUMP, // Compress doesn't know how to detect JARs, see COMPRESS-91 // ArchiveStreamFactory.JAR, - ArchiveStreamFactory.SEVEN_Z, ArchiveStreamFactory.TAR, ArchiveStreamFactory.ZIP }) { + ArchiveStreamFactory.LHA, ArchiveStreamFactory.SEVEN_Z, ArchiveStreamFactory.TAR, ArchiveStreamFactory.ZIP }) { assertEquals(extension, detect("bla." + extension)); } diff --git a/src/test/java/org/apache/commons/compress/archivers/lha/CRC16Test.java b/src/test/java/org/apache/commons/compress/archivers/lha/CRC16Test.java new file mode 100644 index 00000000000..1407b44ab79 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/lha/CRC16Test.java @@ -0,0 +1,36 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.zip.Checksum; + +import org.junit.jupiter.api.Test; + +class CRC16Test { + @Test + void testCRC16() { + final Checksum crc16 = new CRC16(); + crc16.update("123456789".getBytes(StandardCharsets.US_ASCII), 0, 9); + assertEquals(0xbb3d, crc16.getValue()); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntryTest.java b/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntryTest.java new file mode 100644 index 00000000000..4242b0cc6ec --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveEntryTest.java @@ -0,0 +1,69 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Date; + +import org.junit.jupiter.api.Test; + +class LhaArchiveEntryTest { + @Test + void testToStringMinimal() { + final LhaArchiveEntry entry = LhaArchiveEntry.builder() + .setFilename("test1.txt") + .setDirectory(false) + .setSize(57) + .setLastModifiedDate(new Date(1754236942000L)) // 2025-08-03T16:02:22Z + .setCompressedSize(52) + .setCompressionMethod("-lh5-") + .setCrcValue(0x6496) + .get(); + + assertEquals("LhaArchiveEntry[name=test1.txt,directory=false,size=57,lastModifiedDate=2025-08-03T16:02:22Z,compressedSize=52," + + "compressionMethod=-lh5-,crcValue=0x6496]", entry.toString()); + } + + @Test + void testToStringAllFields() { + final LhaArchiveEntry entry = LhaArchiveEntry.builder() + .setFilename("test1.txt") + .setDirectoryName("dir1/") + .setDirectory(false) + .setSize(57) + .setLastModifiedDate(new Date(1754236942000L)) // 2025-08-03T16:02:22Z + .setCompressedSize(52) + .setCompressionMethod("-lh5-") + .setCrcValue(0x6496) + .setOsId(85) + .setUnixPermissionMode(0100644) + .setUnixGroupId(20) + .setUnixUserId(501) + .setMsdosFileAttributes(0x0010) + .setHeaderCrc(0xb772) + .get(); + + assertEquals( + "LhaArchiveEntry[name=dir1/test1.txt,directory=false,size=57,lastModifiedDate=2025-08-03T16:02:22Z,compressedSize=52," + + "compressionMethod=-lh5-,crcValue=0x6496,osId=85,unixPermissionMode=100644,msdosFileAttributes=0010,headerCrc=0xb772]", + entry.toString()); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStreamTest.java new file mode 100644 index 00000000000..bd1176ea3f7 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/lha/LhaArchiveInputStreamTest.java @@ -0,0 +1,1646 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.compress.archivers.ArchiveException; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class LhaArchiveInputStreamTest extends AbstractTest { + private static final int[] VALID_HEADER_LEVEL_0_FILE = new int[] { + 0x2b, 0x70, 0x2d, 0x6c, 0x68, 0x35, 0x2d, 0x34, 0x00, 0x00, 0x00, 0x39, 0x00, 0x00, 0x00, 0x4b, // |+p-lh5-4...9...K| + 0x80, 0x03, 0x5b, 0x20, 0x00, 0x09, 0x74, 0x65, 0x73, 0x74, 0x31, 0x2e, 0x74, 0x78, 0x74, 0x96, // |..[ ..test1.txt.| + 0x64, 0x55, 0x00, 0xef, 0x6b, 0x8f, 0x68, 0xa4, 0x81, 0xf5, 0x01, 0x14, 0x00, 0x00, 0x39, 0x4a, // |dU..k.h.......9J| + 0x8e, 0x8d, 0x33, 0xb7, 0x3e, 0x80, 0x1f, 0xe8, 0x4d, 0x01, 0x3a, 0x00, 0x12, 0xb4, 0xc7, 0x83, // |..3.>...M.:.....| + 0x5a, 0x8d, 0xf4, 0x03, 0xe9, 0xe3, 0xc0, 0x3b, 0xae, 0xc0, 0xc4, 0xe6, 0x78, 0x28, 0xa1, 0x78, // |Z......;....x(.x| + 0x75, 0x60, 0xd3, 0xaa, 0x76, 0x4e, 0xbb, 0xc1, 0x7c, 0x1d, 0x9a, 0x63, 0xaf, 0xc3, 0xe4, 0xaf, // |u`..vN..|..c....| + 0x7c, 0x00 // ||.| + }; + + private static final int[] VALID_HEADER_LEVEL_0_FILE_MACOS_UTF8 = new int[] { + 0x31, 0x65, 0x2d, 0x6c, 0x68, 0x30, 0x2d, 0x0d, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x06, // |1e-lh0-.........| + 0x8c, 0x0d, 0x5b, 0x20, 0x00, 0x0f, 0x74, 0x65, 0x73, 0x74, 0x2d, 0xc3, 0xa5, 0xc3, 0xa4, 0xc3, // |..[ ..test-.....| + 0xb6, 0x2e, 0x74, 0x78, 0x74, 0x57, 0x77, 0x55, 0x00, 0xfc, 0xaf, 0x9c, 0x68, 0xa4, 0x81, 0xf5, // |..txtWwU....h...| + 0x01, 0x14, 0x00, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x0a, // |...Hello World!.| + 0x00 // |.| + }; + + private static final int[] VALID_HEADER_LEVEL_0_FILE_MSDOS_ISO8859_1 = new int[] { + 0x22, 0x6b, 0x2d, 0x6c, 0x68, 0x30, 0x2d, 0x0e, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x52, // |"k-lh0-........R| + 0x54, 0x0d, 0x5b, 0x20, 0x00, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x2d, 0xe5, 0xe4, 0xf6, 0x2e, 0x74, // |T.[ ..test-....t| + 0x78, 0x74, 0xb4, 0xc9, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, // |xt..Hello World!| + 0x0d, 0x0a, 0x00 // |...| + }; + + private static final int[] VALID_HEADER_LEVEL_1_FILE = new int[] { + 0x22, 0x09, 0x2d, 0x6c, 0x68, 0x35, 0x2d, 0x47, 0x00, 0x00, 0x00, 0x39, 0x00, 0x00, 0x00, 0x4b, // |".-lh5-G...9...K| + 0x80, 0x03, 0x5b, 0x20, 0x01, 0x09, 0x74, 0x65, 0x73, 0x74, 0x31, 0x2e, 0x74, 0x78, 0x74, 0x96, // |..[ ..test1.txt.| + 0x64, 0x55, 0x05, 0x00, 0x50, 0xa4, 0x81, 0x07, 0x00, 0x51, 0x14, 0x00, 0xf5, 0x01, 0x07, 0x00, // |dU..P....Q......| + 0x54, 0xef, 0x6b, 0x8f, 0x68, 0x00, 0x00, 0x00, 0x39, 0x4a, 0x8e, 0x8d, 0x33, 0xb7, 0x3e, 0x80, // |T.k.h...9J..3.>.| + 0x1f, 0xe8, 0x4d, 0x01, 0x3a, 0x00, 0x12, 0xb4, 0xc7, 0x83, 0x5a, 0x8d, 0xf4, 0x03, 0xe9, 0xe3, // |..M.:.....Z.....| + 0xc0, 0x3b, 0xae, 0xc0, 0xc4, 0xe6, 0x78, 0x28, 0xa1, 0x78, 0x75, 0x60, 0xd3, 0xaa, 0x76, 0x4e, // |.;....x(.xu`..vN| + 0xbb, 0xc1, 0x7c, 0x1d, 0x9a, 0x63, 0xaf, 0xc3, 0xe4, 0xaf, 0x7c, 0x00 // |..|..c....|.| + }; + + private static final int[] VALID_HEADER_LEVEL_1_FILE_MSDOS_WITH_CHECKSUM_AND_CRC = new int[] { + 0x19, 0x36, 0x2d, 0x6c, 0x68, 0x64, 0x2d, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3d, // |.6-lhd-........=| + 0x77, 0x0d, 0x5b, 0x20, 0x01, 0x00, 0x00, 0x00, 0x4d, 0x08, 0x00, 0x02, 0x64, 0x69, 0x72, 0x31, // |w.[ ....M...dir1| + 0xff, 0x05, 0x00, 0x40, 0x10, 0x00, 0x05, 0x00, 0x00, 0x72, 0xb7, 0x00, 0x00, 0x22, 0x10, 0x2d, // |...@.....r...".-| + 0x6c, 0x68, 0x30, 0x2d, 0x1b, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x52, 0x54, 0x0d, 0x5b, // |lh0-........RT.[| + 0x20, 0x01, 0x09, 0x74, 0x65, 0x73, 0x74, 0x31, 0x2e, 0x74, 0x78, 0x74, 0xb4, 0xc9, 0x4d, 0x08, // | ..test1.txt..M.| + 0x00, 0x02, 0x64, 0x69, 0x72, 0x31, 0xff, 0x05, 0x00, 0x00, 0x71, 0x9b, 0x00, 0x00, 0x48, 0x65, // |..dir1....q...He| + 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x0d, 0x0a, 0x00 // |llo World!...| + }; + + private static final int[] VALID_HEADER_LEVEL_2_FILE = new int[] { + 0x37, 0x00, 0x2d, 0x6c, 0x68, 0x35, 0x2d, 0x34, 0x00, 0x00, 0x00, 0x39, 0x00, 0x00, 0x00, 0xef, // |7.-lh5-4...9....| + 0x6b, 0x8f, 0x68, 0x20, 0x02, 0x96, 0x64, 0x55, 0x05, 0x00, 0x00, 0xa5, 0x01, 0x0c, 0x00, 0x01, // |k.h ..dU........| + 0x74, 0x65, 0x73, 0x74, 0x31, 0x2e, 0x74, 0x78, 0x74, 0x05, 0x00, 0x50, 0xa4, 0x81, 0x07, 0x00, // |test1.txt..P....| + 0x51, 0x14, 0x00, 0xf5, 0x01, 0x00, 0x00, 0x00, 0x39, 0x4a, 0x8e, 0x8d, 0x33, 0xb7, 0x3e, 0x80, // |Q.......9J..3.>.| + 0x1f, 0xe8, 0x4d, 0x01, 0x3a, 0x00, 0x12, 0xb4, 0xc7, 0x83, 0x5a, 0x8d, 0xf4, 0x03, 0xe9, 0xe3, // |..M.:.....Z.....| + 0xc0, 0x3b, 0xae, 0xc0, 0xc4, 0xe6, 0x78, 0x28, 0xa1, 0x78, 0x75, 0x60, 0xd3, 0xaa, 0x76, 0x4e, // |.;....x(.xu`..vN| + 0xbb, 0xc1, 0x7c, 0x1d, 0x9a, 0x63, 0xaf, 0xc3, 0xe4, 0xaf, 0x7c, 0x00 // |..|..c....|.| + }; + + @Test + void testInvalidHeaderLevelLength() throws IOException { + final byte[] data = new byte[] { 0x04, 0x00, 0x00, 0x00, 0x00, 0x00 }; + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header length"); + } catch (ArchiveException e) { + assertEquals("Invalid header length", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + data[20] = 4; // Change the header level to an invalid value + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header level"); + } catch (ArchiveException e) { + assertEquals("Invalid header level: 4", e.getMessage()); + } + } + + @Test + void testUnsupportedCompressionMethod() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + data[1] = (byte) 0x9c; // Change the header checksum + data[5] = 'a'; // Change the compression method to an unsupported value + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("-lha-", entry.getCompressionMethod()); + + assertFalse(archive.canReadEntryData(entry)); + + try { + IOUtils.toByteArray(archive); + fail("Expected ArchiveException for unsupported compression method"); + } catch (ArchiveException e) { + assertEquals("Unsupported compression method: -lha-", e.getMessage()); + } + } + } + + @Test + void testReadDataBeforeEntry() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + try { + IOUtils.toByteArray(archive); + fail("Expected IllegalStateException for reading data before entry"); + } catch (IllegalStateException e) { + assertEquals("No current entry", e.getMessage()); + } + } + } + + @Test + void testParseHeaderLevel0File() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_0_FILE))) + .get()) { + + // Entry should be parsed correctly + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(57, entry.getSize()); + assertEquals(1754236942000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-03T16:02:22Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(52, entry.getCompressedSize()); + assertEquals("-lh5-", entry.getCompressionMethod()); + assertEquals(0x6496, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel0FileMacosUtf8() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_0_FILE_MACOS_UTF8))) + .setCharset(StandardCharsets.UTF_8) + .get()) { + + // Entry name should be parsed correctly + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test-\u00E5\u00E4\u00F6.txt", entry.getName()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel0FileMsdosIso88591() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_0_FILE_MSDOS_ISO8859_1))) + .setCharset(StandardCharsets.ISO_8859_1) + .get()) { + + // Entry name should be parsed correctly + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test-\u00E5\u00E4\u00F6.txt", entry.getName()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel0FileMsdosIso88591DefaultEncoding() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_0_FILE_MSDOS_ISO8859_1))) + .get()) { + + // First entry should be with replacement characters for unsupported characters + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test-\uFFFD\uFFFD\uFFFD.txt", entry.getName()); // Unicode replacement characters for unsupported characters + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testInvalidHeaderLevel0Length() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + data[0] = 0x10; // Change the first byte to an invalid length + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header length"); + } catch (ArchiveException e) { + assertEquals("Invalid header level 0 length: 18", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel0Checksum() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + data[1] = 0x55; // Change the second byte to an invalid header checksum + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header checksum"); + } catch (ArchiveException e) { + assertEquals("Invalid header level 0 checksum", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel0FilenameLength() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + + data[21] = 22; // Change the length of the filename + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid filename"); + } catch (ArchiveException e) { + assertEquals("Invalid pathname length", e.getMessage()); + } + } + + @Test + void testParseHeaderLevel0FileWithFoldersMacos() throws IOException { + // The lha file was generated by LHa for UNIX version 1.14i-ac20211125 for Macos + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-macos-l0.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755090690000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T13:11:30Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755090728000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T13:12:08Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755090728000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T13:12:08Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755090812000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T13:13:32Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755090812000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T13:13:32Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel0FileWithFoldersMsdos() throws IOException { + // The lha file was generated by LHA32 v2.67.00 for Windows + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-msdos-l0.lha")) + .setFileSeparatorChar('/') + .get()) { + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081308000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:08Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081336000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081340000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:40Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel0Larc() throws IOException { + // This archive was created using LArc 3.33 on MS-DOS + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-msdos-l0-lz4.lzs")).get()) { + // Check file entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("TEST1.TXT", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1757247072000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-09-07T12:11:12Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lz4-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertNull(entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel1File() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_1_FILE))) + .get()) { + + // Entry should be parsed correctly + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(57, entry.getSize()); + assertEquals(1754229743000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-03T14:02:23Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(52, entry.getCompressedSize()); + assertEquals("-lh5-", entry.getCompressionMethod()); + assertEquals(0x6496, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel1FileMsdosChecksumAndCrc() throws IOException { + // The lha file was generated by LHA32 v2.67.00 for Windows + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_1_FILE_MSDOS_WITH_CHECKSUM_AND_CRC))) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755097078000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T14:57:58Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0xb772, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x9b71, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testInvalidHeaderLevel1Length() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_1_FILE); + + data[0] = 0x10; // Change the first byte to an invalid length + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header length"); + } catch (ArchiveException e) { + assertEquals("Invalid header level 1 length: 18", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel1Checksum() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_1_FILE); + + data[1] = 0x55; // Change the second byte to an invalid header checksum + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header checksum"); + } catch (ArchiveException e) { + assertEquals("Invalid header level 1 checksum", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel1Crc() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_1_FILE_MSDOS_WITH_CHECKSUM_AND_CRC); + + // Change header CRC to an invalid value + data[41] = 0x33; + data[42] = 0x22; + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header checksum"); + } catch (ArchiveException e) { + assertEquals("Invalid header CRC expected=0xb772 found=0x2233", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel1FilenameLength() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_1_FILE); + + data[21] = 10; // Change the length of the filename + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid filename"); + } catch (ArchiveException e) { + assertEquals("Invalid pathname length", e.getMessage()); + } + } + + @Test + void testParseHeaderLevel1FileWithFoldersMacos() throws IOException { + // The lha file was generated by LHa for UNIX version 1.14i-ac20211125 for Macos + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-macos-l1.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083490000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:11:30Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083529000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:12:09Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755083529000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:12:09Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083612000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:13:32Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755083612000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:13:32Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertNull(entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel1FileWithFoldersMsdos() throws IOException { + // The lha file was generated by LHA32 v2.67.00 for Windows + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-msdos-l1.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081308000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:08Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0xd458, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081336000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0x40de, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x34b0, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081340000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:40Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0x21b2, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate()).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), convertSystemTimeZoneDateToUTC(entry.getLastModifiedDate())); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x8f0c, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel2File() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(new ByteArrayInputStream(toByteArray(VALID_HEADER_LEVEL_2_FILE))) + .setFileSeparatorChar('/') + .get()) { + + // Entry should be parsed correctly + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(57, entry.getSize()); + assertEquals(1754229743000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-03T14:02:23Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(52, entry.getCompressedSize()); + assertEquals("-lh5-", entry.getCompressionMethod()); + assertEquals(0x6496, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x01a5, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testInvalidHeaderLevel2Length() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_2_FILE); + + data[0] = 25; // Change the first byte to an invalid length + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header length"); + } catch (ArchiveException e) { + assertEquals("Invalid header level 2 length: 25", e.getMessage()); + } + } + + @Test + void testInvalidHeaderLevel2Checksum() throws IOException { + final byte[] data = toByteArray(VALID_HEADER_LEVEL_2_FILE); + + // Change header CRC to an invalid value + data[27] = 0x33; + data[28] = 0x22; + + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(new ByteArrayInputStream(data)).get()) { + archive.getNextEntry(); + fail("Expected ArchiveException for invalid header checksum"); + } catch (ArchiveException e) { + assertEquals("Invalid header CRC expected=0x01a5 found=0x2233", e.getMessage()); + } + } + + @Test + void testParseHeaderLevel2FileWithFoldersAmiga() throws IOException { + // The lha file was generated by LhA 2.15 on Amiga + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-amiga-l2.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // No -lhd- directory entries in Amiga LHA files, so we expect only file entries + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(65, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0xe1a5, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(65, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0xd6b0, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel2FileWithFoldersMacos() throws IOException { + // The lha file was generated by LHa for UNIX version 1.14i-ac20211125 for Macos + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-macos-l2.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083490000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:11:30Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0xf3f7, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083529000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:12:09Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x50d3, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755083529000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:12:09Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x589e, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755083612000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:13:32Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(040755, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x126d, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(13, entry.getSize()); + assertEquals(1755083612000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T11:13:32Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(13, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0x7757, entry.getCrcValue()); + assertEquals(85, entry.getOsId()); + assertEquals(0100644, entry.getUnixPermissionMode()); + assertEquals(20, entry.getUnixGroupId()); + assertEquals(501, entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0xdbdd, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel2FileWithFoldersMsdos() throws IOException { + // The lha file was generated by LHA32 v2.67.00 for Windows + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-msdos-l2.lha")) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry entry; + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081308000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:08Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0x496a, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081336000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0xebe7, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-1/test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x214a, entry.getHeaderCrc()); + + // Check directory entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/", entry.getName()); + assertTrue(entry.isDirectory()); + assertEquals(0, entry.getSize()); + assertEquals(1755081341000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:35:41Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(0, entry.getCompressedSize()); + assertEquals("-lhd-", entry.getCompressionMethod()); + assertEquals(0x0000, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0010, entry.getMsdosFileAttributes()); + assertEquals(0x74ca, entry.getHeaderCrc()); + + // Check file entry + entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("dir1/dir1-2/test2.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertNull(entry.getMsdosFileAttributes()); + assertEquals(0x165f, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseHeaderLevel2FileWithMsdosAttributes() throws IOException { + // The lha file was generated by LHA32 v2.67.00 for Windows + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-msdos-l2-attrib.lha")).get()) { + // Check file entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("test1.txt", entry.getName()); + assertFalse(entry.isDirectory()); + assertEquals(14, entry.getSize()); + assertEquals(1755081276000L, entry.getLastModifiedDate().getTime()); + assertEquals(ZonedDateTime.parse("2025-08-13T10:34:36Z"), entry.getLastModifiedDate().toInstant().atZone(ZoneOffset.UTC)); + assertEquals(14, entry.getCompressedSize()); + assertEquals("-lh0-", entry.getCompressionMethod()); + assertEquals(0xc9b4, entry.getCrcValue()); + assertEquals(77, entry.getOsId()); + assertNull(entry.getUnixPermissionMode()); + assertNull(entry.getUnixGroupId()); + assertNull(entry.getUnixUserId()); + assertEquals(0x0021, entry.getMsdosFileAttributes()); + assertEquals(0x14bb, entry.getHeaderCrc()); + + // No more entries expected + assertNull(archive.getNextEntry()); + } + } + + @Test + void testParseExtendedHeaderTooShort() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + try { + archive.parseExtendedHeader(toByteBuffer(0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderCommon() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x00, 0x22, 0x33, 0x00, 0x00), entryBuilder); + assertEquals(0x3322, entryBuilder.get().getHeaderCrc()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x00, 0x22, 0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderFilename() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + assertEquals("test.txt", entryBuilder.get().getName()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x01, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderDirectoryName() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newEmptyInputStream()) + .setFileSeparatorChar('/') + .get()) { + + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x02, 'd', 'i', 'r', '1', 0xff, 0x00, 0x00), entryBuilder); + assertEquals("dir1/", entryBuilder.get().getName()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x02, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderFilenameAndDirectoryName() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newEmptyInputStream()) + .setFileSeparatorChar('/') + .get()) { + + LhaArchiveEntry.Builder entryBuilder; + + // Test filename and directory name order + entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + archive.parseExtendedHeader(toByteBuffer(0x02, 'd', 'i', 'r', '1', 0xff, 0x00, 0x00), entryBuilder); + assertEquals("dir1/test.txt", entryBuilder.get().getName()); + + // Test filename and directory name order, no trailing slash + entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + archive.parseExtendedHeader(toByteBuffer(0x02, 'd', 'i', 'r', '1', 0x00, 0x00), entryBuilder); + assertEquals("dir1/test.txt", entryBuilder.get().getName()); + + // Test directory name and filename order + entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x02, 'd', 'i', 'r', '1', 0xff, 0x00, 0x00), entryBuilder); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + assertEquals("dir1/test.txt", entryBuilder.get().getName()); + + // Test directory name and filename order, no trailing slash + entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x02, 'd', 'i', 'r', '1', 0x00, 0x00), entryBuilder); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + assertEquals("dir1/test.txt", entryBuilder.get().getName()); + + // Test empty directory name, no trailing slash + entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x02, 0x00, 0x00), entryBuilder); + archive.parseExtendedHeader(toByteBuffer(0x01, 't', 'e', 's', 't', '.', 't', 'x', 't', 0x00, 0x00), entryBuilder); + assertEquals("test.txt", entryBuilder.get().getName()); + } + } + + @Test + void testParseExtendedHeaderUnixPermission() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x50, 0xa4, 0x81, 0x00, 0x00), entryBuilder); + assertEquals(0x81a4, entryBuilder.get().getUnixPermissionMode()); + assertEquals(0100644, entryBuilder.get().getUnixPermissionMode()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x50, 0xa4, 0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderUnixUidGid() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x51, 0x14, 0x00, 0xf5, 0x01, 0x00, 0x00), entryBuilder); + assertEquals(0x0014, entryBuilder.get().getUnixGroupId()); + assertEquals(0x01f5, entryBuilder.get().getUnixUserId()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x51, 0x14, 0x00, 0xf5, 0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderUnixTimestamp() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x54, 0x5c, 0x73, 0x9c, 0x68, 0x00, 0x00), entryBuilder); + assertEquals(0x689c735cL, entryBuilder.get().getLastModifiedDate().getTime() / 1000); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x54, 0x5c, 0x73, 0x9c, 0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testParseExtendedHeaderMSdosFileAttributes() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + // Valid + final LhaArchiveEntry.Builder entryBuilder = LhaArchiveEntry.builder(); + archive.parseExtendedHeader(toByteBuffer(0x40, 0x10, 0x00, 0x00, 0x00), entryBuilder); + assertEquals(0x10, entryBuilder.get().getMsdosFileAttributes()); + + // Invalid length + try { + archive.parseExtendedHeader(toByteBuffer(0x40, 0x10, 0x00, 0x00), entryBuilder); + fail("Expected ArchiveException for invalid extended header length"); + } catch (ArchiveException e) { + assertEquals("Invalid extended header length", e.getMessage()); + } + } + } + + @Test + void testDecompressLh0() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder() + .setInputStream(newInputStream("test-macos-l0.lha")) + .get()) { + + final List files = new ArrayList<>(); + files.add("dir1" + File.separatorChar); + files.add("dir1" + File.separatorChar + "dir1-1" + File.separatorChar); + files.add("dir1" + File.separatorChar + "dir1-1" + File.separatorChar + "test1.txt"); + files.add("dir1" + File.separatorChar + "dir1-2" + File.separatorChar); + files.add("dir1" + File.separatorChar + "dir1-2" + File.separatorChar + "test2.txt"); + checkArchiveContent(archive, files); + } + } + + @Test + void testDecompressLh4() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-amiga-l0-lh4.lha")).get()) { + final List files = new ArrayList<>(); + files.add("lorem-ipsum.txt"); + checkArchiveContent(archive, files); + } + } + + @Test + void testDecompressLh5() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh5.lha")).get()) { + final List files = new ArrayList<>(); + files.add("lorem-ipsum.txt"); + checkArchiveContent(archive, files); + } + } + + /** + * Test decompressing a file with lh5 compression that contains only one characters and thus is + * basically RLE encoded. The distance tree contains only one entry (root node). + */ + @Test + void testDecompressLh5Rle() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh5-rle.lha")).get()) { + final List files = new ArrayList<>(); + files.add("rle.txt"); + checkArchiveContent(archive, files); + } + } + + @Test + void testDecompressLh6() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh6.lha")).get()) { + final List files = new ArrayList<>(); + files.add("lorem-ipsum.txt"); + checkArchiveContent(archive, files); + } + } + + @Test + void testDecompressLh7() throws Exception { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh7.lha")).get()) { + final List files = new ArrayList<>(); + files.add("lorem-ipsum.txt"); + checkArchiveContent(archive, files); + } + } + + @Test + void testDecompressLz4() throws Exception { + // This archive was created using LArc 3.33 on MS-DOS + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-msdos-l0-lz4.lzs")).get()) { + final List files = new ArrayList<>(); + files.add("TEST1.TXT"); + checkArchiveContent(archive, files); + } + } + + @Test + void testMatches() { + byte[] data; + + assertTrue(LhaArchiveInputStream.matches(toByteArray(VALID_HEADER_LEVEL_0_FILE), VALID_HEADER_LEVEL_0_FILE.length)); + assertTrue(LhaArchiveInputStream.matches(toByteArray(VALID_HEADER_LEVEL_1_FILE), VALID_HEADER_LEVEL_1_FILE.length)); + assertTrue(LhaArchiveInputStream.matches(toByteArray(VALID_HEADER_LEVEL_2_FILE), VALID_HEADER_LEVEL_2_FILE.length)); + + // Header to short + data = toByteArray(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09); + assertFalse(LhaArchiveInputStream.matches(data, data.length)); + + // Change the header level to an invalid value + data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + data[20] = 4; + assertFalse(LhaArchiveInputStream.matches(data, data.length)); + + // Change the compression method to an invalid value + data = toByteArray(VALID_HEADER_LEVEL_0_FILE); + data[6] = 0x08; + assertFalse(LhaArchiveInputStream.matches(data, data.length)); + } + + @Test + void testGetCompressionMethod() throws IOException { + assertEquals("-lh0-", LhaArchiveInputStream.getCompressionMethod(ByteBuffer.wrap(toByteArray(0x00, 0x00, '-', 'l', 'h', '0', '-')))); + assertEquals("-lhd-", LhaArchiveInputStream.getCompressionMethod(ByteBuffer.wrap(toByteArray(0x00, 0x00, '-', 'l', 'h', 'd', '-')))); + + try { + LhaArchiveInputStream.getCompressionMethod(ByteBuffer.wrap(toByteArray(0x00, 0x00, '-', 'l', 'h', '0', 0xff))); + fail("Expected ArchiveException for invalid compression method"); + } catch (ArchiveException e) { + assertEquals("Invalid compression method: 0x2d 0x6c 0x68 0x30 0xff", e.getMessage()); + } + } + + @Test + void testGetPathnameUnixFileSeparatorCharDefaultEncoding() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).setFileSeparatorChar('/').get()) { + assertEquals("", getPathname(is)); + assertEquals("", getPathname(is, 0xff)); + assertEquals("a", getPathname(is, 'a')); + assertEquals("folder/", getPathname(is, 'f', 'o', 'l', 'd', 'e', 'r', 0xff)); + assertEquals("folder/file.txt", getPathname(is, 'f', 'o', 'l', 'd', 'e', 'r', 0xff, 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + assertEquals("folder/file.txt", getPathname(is, 0xff, 'f', 'o', 'l', 'd', 'e', 'r', 0xff, 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + assertEquals("folder/file.txt", getPathname(is, '\\', 'f', 'o', 'l', 'd', 'e', 'r', '\\', 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + + // Unicode replacement characters for unsupported characters + assertEquals("\uFFFD/\uFFFD/\uFFFD.txt", getPathname(is, 0xe5, 0xff, 0xe4, 0xff, 0xf6, '.', 't', 'x', 't')); + assertEquals("\uFFFD/\uFFFD/\uFFFD.txt", getPathname(is, 0xe5, '\\', 0xe4, '\\', 0xf6, '.', 't', 'x', 't')); + } + } + + @Test + void testGetPathnameUnixFileSeparatorCharIso88591() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder() + .setInputStream(newEmptyInputStream()) + .setCharset(StandardCharsets.ISO_8859_1) + .setFileSeparatorChar('/') + .get()) { + + assertEquals("\u00E5/\u00E4/\u00F6.txt", getPathname(is, 0xe5, 0xff, 0xe4, 0xff, 0xf6, '.', 't', 'x', 't')); + assertEquals("\u00E5/\u00E4/\u00F6.txt", getPathname(is, 0xe5, '\\', 0xe4, '\\', 0xf6, '.', 't', 'x', 't')); + } + } + + @Test + void testGetPathnameWindowsFileSeparatorCharDefaultEncoding() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).setFileSeparatorChar('\\').get()) { + assertEquals("folder\\", getPathname(is, 'f', 'o', 'l', 'd', 'e', 'r', 0xff)); + assertEquals("folder\\file.txt", getPathname(is, 'f', 'o', 'l', 'd', 'e', 'r', 0xff, 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + assertEquals("folder\\file.txt", getPathname(is, 0xff, 'f', 'o', 'l', 'd', 'e', 'r', 0xff, 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + assertEquals("folder\\file.txt", getPathname(is, '\\', 'f', 'o', 'l', 'd', 'e', 'r', '\\', 'f', 'i', 'l', 'e', '.', 't', 'x', 't')); + + // Unicode replacement characters for unsupported characters + assertEquals("\uFFFD\\\uFFFD\\\uFFFD.txt", getPathname(is, 0xe5, 0xff, 0xe4, 0xff, 0xf6, '.', 't', 'x', 't')); + assertEquals("\uFFFD\\\uFFFD\\\uFFFD.txt", getPathname(is, 0xe5, '\\', 0xe4, '\\', 0xf6, '.', 't', 'x', 't')); + } + } + + @Test + void testGetPathnameNegativeLength() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + try { + is.getPathname(ByteBuffer.wrap(new byte[0]), -1); + fail("Expected ArchiveException when pathname length is negative"); + } catch (ArchiveException e) { + assertEquals("Pathname length is negative", e.getMessage()); + } + } + } + + @Test + void testGetPathnameTooLong() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + try { + final byte[] pathname = new byte[4097]; + is.getPathname(ByteBuffer.wrap(pathname), pathname.length); + fail("Expected ArchiveException when pathname is longer than the maximum allowed"); + } catch (ArchiveException e) { + assertEquals("Pathname is longer than the maximum allowed (4097 > 4096)", e.getMessage()); + } + } + } + + @Test + void testGetPathnameInvalidLength() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder().setInputStream(newEmptyInputStream()).get()) { + try { + final byte[] pathname = new byte[] { 'a', 'b', 'c' }; + is.getPathname(ByteBuffer.wrap(pathname), pathname.length + 1); + fail("Expected ArchiveException for invalid pathname length"); + } catch (ArchiveException e) { + assertEquals("Invalid pathname length", e.getMessage()); + } + } + } + + @Test + void testGetPathnameWindowsFileSeparatorCharIso88591() throws IOException, UnsupportedEncodingException { + try (LhaArchiveInputStream is = LhaArchiveInputStream.builder() + .setInputStream(newEmptyInputStream()) + .setCharset(StandardCharsets.ISO_8859_1) + .setFileSeparatorChar('\\') + .get()) { + + assertEquals("\u00E5\\\u00E4\\\u00F6.txt", getPathname(is, 0xe5, 0xff, 0xe4, 0xff, 0xf6, '.', 't', 'x', 't')); + assertEquals("\u00E5\\\u00E4\\\u00F6.txt", getPathname(is, 0xe5, '\\', 0xe4, '\\', 0xf6, '.', 't', 'x', 't')); + } + } + + private static byte[] toByteArray(final int... data) { + final byte[] bytes = new byte[data.length]; + for (int i = 0; i < data.length; i++) { + bytes[i] = (byte) data[i]; + } + return bytes; + } + + private static ByteBuffer toByteBuffer(final int... data) { + return ByteBuffer.wrap(toByteArray(data)).order(ByteOrder.LITTLE_ENDIAN); + } + + private String getPathname(final LhaArchiveInputStream is, final int... filepathBuffer) throws ArchiveException, UnsupportedEncodingException { + return is.getPathname(ByteBuffer.wrap(toByteArray(filepathBuffer)), filepathBuffer.length); + } + + private InputStream newEmptyInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + /** + * The timestamp used in header level 0 and 1 entries has no time zone information and is + * converted in the system default time zone. This method converts the date to UTC to verify + * the timestamp in unit tests. + * + * @param date the date to convert + * @return a ZonedDateTime in UTC + */ + private ZonedDateTime convertSystemTimeZoneDateToUTC(final Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).withZoneSameLocal(ZoneOffset.UTC); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/CircularBufferTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/CircularBufferTest.java deleted file mode 100644 index e5656d00cf2..00000000000 --- a/src/test/java/org/apache/commons/compress/archivers/zip/CircularBufferTest.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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.zip; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class CircularBufferTest { - - @Test - void testCopy() { - final CircularBuffer buffer = new CircularBuffer(16); - - buffer.put(1); - buffer.put(2); - buffer.get(); - buffer.get(); - - // copy uninitialized data - buffer.copy(6, 8); - - for (int i = 2; i < 6; i++) { - assertEquals(0, buffer.get(), "buffer[" + i + "]"); - } - assertEquals(1, buffer.get(), "buffer[" + 6 + "]"); - assertEquals(2, buffer.get(), "buffer[" + 7 + "]"); - assertEquals(0, buffer.get(), "buffer[" + 8 + "]"); - assertEquals(0, buffer.get(), "buffer[" + 9 + "]"); - - for (int i = 10; i < 14; i++) { - buffer.put(i); - buffer.get(); - } - - assertFalse(buffer.available(), "available"); - - // copy data and wrap - buffer.copy(2, 8); - - for (int i = 14; i < 18; i++) { - assertEquals(i % 2 == 0 ? 12 : 13, buffer.get(), "buffer[" + i + "]"); - } - } - - @Test - void testPutAndGet() { - final int size = 16; - final CircularBuffer buffer = new CircularBuffer(size); - for (int i = 0; i < size / 2; i++) { - buffer.put(i); - } - - assertTrue(buffer.available(), "available"); - - for (int i = 0; i < size / 2; i++) { - assertEquals(i, buffer.get(), "buffer[" + i + "]"); - } - - assertEquals(-1, buffer.get()); - assertFalse(buffer.available(), "available"); - } -} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStreamTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStreamTest.java new file mode 100644 index 00000000000..e8ea1d55dc9 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/AbstractLhStaticHuffmanCompressorInputStreamTest.java @@ -0,0 +1,157 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteOrder; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.utils.BitInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class AbstractLhStaticHuffmanCompressorInputStreamTest { + @Test + void testInputStreamStatistics() throws IOException { + final int[] compressedData = { + 0x00, 0x05, 0x28, 0x04, 0x4b, 0xfc, 0x16, 0xed, + 0x37, 0x00, 0x43, 0x00 + }; + + try (Lh5CompressorInputStream in = createLh5CompressorInputStream(compressedData)) { + final byte[] decompressedData = IOUtils.toByteArray(in); + + assertEquals(1024, decompressedData.length); + for (int i = 0; i < decompressedData.length; i++) { + assertEquals('A', decompressedData[i], "Byte at position " + i); + } + + assertEquals(12, in.getCompressedCount()); + assertEquals(1024, in.getUncompressedCount()); + } + } + + @Test + void testReadCommandDecodingTreeWithSingleValue() throws IOException { + final BinaryTree tree = createLh5CompressorInputStream( + 0b00000000, 0b00111111 // 5 bits length (0x00) and 5 bits the root value (0x00) + ).readCommandDecodingTree(); + + assertEquals(0, tree.read(new BitInputStream(new ByteArrayInputStream(new byte[0]), ByteOrder.BIG_ENDIAN))); + } + + @Test + void testReadCommandDecodingTreeWithInvalidSize() throws IOException { + try { + createLh5CompressorInputStream( + 0b10100000, 0b00000000 // 5 bits length (0x14 = 20) + ).readCommandDecodingTree(); + + fail("Expected CompressorException for table invalid size"); + } catch (CompressorException e) { + assertEquals("Code length table has invalid size (20 > 19)", e.getMessage()); + } + } + + @Test + void testReadCommandTreeWithSingleValue() throws IOException { + final BinaryTree tree = createLh5CompressorInputStream( + 0b00000000, 0b01111111, 0b01000000 // 9 bits length (0x00) and 9 bits the root value (0x01fd = 509) + ).readCommandTree(new BinaryTree(new int [] { 0 })); + + assertEquals(0x01fd, tree.read(new BitInputStream(new ByteArrayInputStream(new byte[0]), ByteOrder.BIG_ENDIAN))); + } + + @Test + void testReadCommandTreeWithInvalidSize() throws IOException { + try { + createLh5CompressorInputStream( + 0b11111111, 0b10000000 // 9 bits length (0x01ff = 511) + ).readCommandTree(new BinaryTree(new int [] { 0 })); + + fail("Expected CompressorException for table invalid size"); + } catch (CompressorException e) { + assertEquals("Code length table has invalid size (511 > 510)", e.getMessage()); + } + } + + @Test + void testReadCommandTreeUnexpectedEndOfStream() throws IOException { + try { + createLh5CompressorInputStream( + 0b00000000, 0b01111111 // 9 bits length (0x00) and only 8 bits instead of expected 9 bits which will cause an unexpected end of stream + ).readCommandTree(new BinaryTree(new int [] { 0 })); + fail("Expected CompressorException for unexpected end of stream"); + } catch (CompressorException e) { + assertEquals("Unexpected end of stream", e.getMessage()); + } + } + + @Test + void testReadCodeLength() throws IOException { + assertEquals(0, createLh5CompressorInputStream(0x00, 0x00).readCodeLength()); // 0000 0000 0000 0000 + assertEquals(1, createLh5CompressorInputStream(0x20, 0x00).readCodeLength()); // 0010 0000 0000 0000 + assertEquals(2, createLh5CompressorInputStream(0x40, 0x00).readCodeLength()); // 0100 0000 0000 0000 + assertEquals(3, createLh5CompressorInputStream(0x60, 0x00).readCodeLength()); // 0110 0000 0000 0000 + assertEquals(4, createLh5CompressorInputStream(0x80, 0x00).readCodeLength()); // 1000 0000 0000 0000 + assertEquals(5, createLh5CompressorInputStream(0xa0, 0x00).readCodeLength()); // 1010 0000 0000 0000 + assertEquals(6, createLh5CompressorInputStream(0xc0, 0x00).readCodeLength()); // 1100 0000 0000 0000 + assertEquals(7, createLh5CompressorInputStream(0xe0, 0x00).readCodeLength()); // 1110 0000 0000 0000 + assertEquals(8, createLh5CompressorInputStream(0xf0, 0x00).readCodeLength()); // 1111 0000 0000 0000 + assertEquals(9, createLh5CompressorInputStream(0xf8, 0x00).readCodeLength()); // 1111 1000 0000 0000 + assertEquals(10, createLh5CompressorInputStream(0xfc, 0x00).readCodeLength()); // 1111 1100 0000 0000 + assertEquals(11, createLh5CompressorInputStream(0xfe, 0x00).readCodeLength()); // 1111 1110 0000 0000 + assertEquals(12, createLh5CompressorInputStream(0xff, 0x00).readCodeLength()); // 1111 1111 0000 0000 + assertEquals(13, createLh5CompressorInputStream(0xff, 0x80).readCodeLength()); // 1111 1111 1000 0000 + assertEquals(14, createLh5CompressorInputStream(0xff, 0xc0).readCodeLength()); // 1111 1111 1100 0000 + assertEquals(15, createLh5CompressorInputStream(0xff, 0xe0).readCodeLength()); // 1111 1111 1110 0000 + assertEquals(16, createLh5CompressorInputStream(0xff, 0xf0).readCodeLength()); // 1111 1111 1111 0000 + + try { + createLh5CompressorInputStream(0xff, 0xf8).readCodeLength(); // 1111 1111 1111 1000 + fail("Expected CompressorException for code length overflow"); + } catch (CompressorException e) { + assertEquals("Code length overflow", e.getMessage()); + } + } + + @Test + void testReadCodeLengthUnexpectedEndOfStream() throws IOException { + try { + createLh5CompressorInputStream(0xff).readCodeLength(); // 1111 1111 EOF + fail("Expected CompressorException for unexpected end of stream"); + } catch (CompressorException e) { + assertEquals("Unexpected end of stream", e.getMessage()); + } + } + + private Lh5CompressorInputStream createLh5CompressorInputStream(final int... data) throws IOException { + final byte[] bytes = new byte[data.length]; + for (int i = 0; i < data.length; i++) { + bytes[i] = (byte) data[i]; + } + + return new Lh5CompressorInputStream(new ByteArrayInputStream(bytes)); + } +} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/BinaryTreeTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/BinaryTreeTest.java new file mode 100644 index 00000000000..18ab1135f32 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/BinaryTreeTest.java @@ -0,0 +1,265 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteOrder; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.utils.BitInputStream; +import org.junit.jupiter.api.Test; + +class BinaryTreeTest { + @Test + void testTree1() throws Exception { + // Special case where the single array value is the root node value + final BinaryTree tree = new BinaryTree(4); + + assertEquals(4, tree.read(createBitInputStream())); // Nothing to read, just return the root value + } + + @Test + void testTree2() throws Exception { + final int[] length = new int[] { 1, 1 }; + // Value: 0 1 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(0, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(1, tree.read(createBitInputStream(0x80))); // 1xxx xxxx + } + + @Test + void testTree3() throws Exception { + final int[] length = new int[] { 1, 0, 1 }; + // Value: 0 1 2 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(0, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(2, tree.read(createBitInputStream(0x80))); // 1xxx xxxx + } + + @Test + void testTree4() throws Exception { + final int[] length = new int[] { 2, 0, 1, 2 }; + // Value: 0 1 2 3 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(2, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(0, tree.read(createBitInputStream(0x80))); // 10xx xxxx + assertEquals(3, tree.read(createBitInputStream(0xc0))); // 11xx xxxx + } + + @Test + void testTree5() throws Exception { + final int[] length = new int[] { 2, 0, 0, 2, 1 }; + // Value: 0 1 2 3 4 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(4, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(0, tree.read(createBitInputStream(0x80))); // 10xx xxxx + assertEquals(3, tree.read(createBitInputStream(0xc0))); // 11xx xxxx + } + + @Test + void testTree6() throws Exception { + final int[] length = new int[] { 1, 0, 2, 3, 3 }; + // Value: 0 1 2 3 4 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(0, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(2, tree.read(createBitInputStream(0x80))); // 10xx xxxx + assertEquals(3, tree.read(createBitInputStream(0xc0))); // 110x xxxx + assertEquals(4, tree.read(createBitInputStream(0xe0))); // 111x xxxx + } + + @Test + void testTree7() throws Exception { + final int[] length = new int[] { 0, 0, 0, 0, 1, 1 }; + // Value: 0 1 2 3 4 5 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(4, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(5, tree.read(createBitInputStream(0x80))); // 1xxx xxxx + } + + @Test + void testTree8() throws Exception { + final int[] length = new int[] { 4, 2, 3, 0, 5, 5, 1 }; + // Value: 0 1 2 3 4 5 6 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(6, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(1, tree.read(createBitInputStream(0x80))); // 10xx xxxx + assertEquals(2, tree.read(createBitInputStream(0xc0))); // 110x xxxx + assertEquals(0, tree.read(createBitInputStream(0xe0))); // 1110 xxxx + assertEquals(4, tree.read(createBitInputStream(0xf0))); // 1111 0xxx + assertEquals(5, tree.read(createBitInputStream(0xf8))); // 1111 1xxx + } + + @Test + void testTree9() throws Exception { + final int[] length = new int[] { 5, 6, 6, 0, 0, 8, 7, 7, 7, 4, 3, 2, 2, 4, 5, 5, 5, 4, 8 }; + // Value: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(11, tree.read(createBitInputStream(0x00))); // 00xx xxxx + assertEquals(12, tree.read(createBitInputStream(0x40))); // 01xx xxxx + assertEquals(10, tree.read(createBitInputStream(0x80))); // 100x xxxx + assertEquals(9, tree.read(createBitInputStream(0xa0))); // 1010 xxxx + assertEquals(13, tree.read(createBitInputStream(0xb0))); // 1011 xxxx + assertEquals(17, tree.read(createBitInputStream(0xc0))); // 1100 xxxx + assertEquals(0, tree.read(createBitInputStream(0xd0))); // 1101 0xxx + assertEquals(14, tree.read(createBitInputStream(0xd8))); // 1101 1xxx + assertEquals(15, tree.read(createBitInputStream(0xe0))); // 1110 0xxx + assertEquals(16, tree.read(createBitInputStream(0xe8))); // 1110 1xxx + assertEquals(1, tree.read(createBitInputStream(0xf0))); // 1111 00xx + assertEquals(2, tree.read(createBitInputStream(0xf4))); // 1111 01xx + assertEquals(6, tree.read(createBitInputStream(0xf8))); // 1111 100x + assertEquals(7, tree.read(createBitInputStream(0xfa))); // 1111 101x + assertEquals(8, tree.read(createBitInputStream(0xfc))); // 1111 110x + assertEquals(5, tree.read(createBitInputStream(0xfe))); // 1111 1110 + assertEquals(18, tree.read(createBitInputStream(0xff))); // 1111 1111 + } + + @Test + void testTree10() throws Exception { + // Maximum length of 510 entries for command tree and maximum supported depth of 16 + final int[] length = new int[] { 4, 7, 7, 8, 7, 9, 8, 9, 7, 10, 8, 10, 7, 10, 8, 10, 7, 9, 8, 9, 8, 10, 8, 12, 8, 10, 9, 11, 9, 9, 8, 10, 6, 9, + 7, 9, 8, 10, 8, 11, 7, 9, 8, 9, 8, 9, 8, 9, 7, 9, 8, 8, 8, 10, 9, 11, 8, 9, 8, 10, 8, 9, 8, 9, 7, 7, 7, 8, 8, 8, 8, 9, 7, 8, 7, 9, 8, 9, + 8, 8, 8, 10, 7, 7, 8, 8, 8, 9, 8, 9, 8, 9, 9, 10, 9, 10, 7, 8, 9, 9, 8, 7, 7, 7, 8, 8, 9, 8, 8, 9, 8, 8, 8, 11, 8, 9, 8, 8, 9, 10, 9, 9, + 8, 10, 8, 10, 9, 9, 7, 9, 9, 10, 9, 10, 9, 9, 9, 10, 9, 11, 10, 11, 9, 10, 8, 10, 9, 11, 9, 10, 10, 12, 9, 11, 9, 12, 10, 14, 10, 14, 10, + 11, 10, 11, 9, 11, 10, 12, 9, 11, 10, 11, 9, 10, 10, 11, 9, 11, 10, 12, 10, 13, 11, 13, 10, 11, 10, 13, 10, 15, 10, 14, 8, 10, 9, 10, 9, + 10, 10, 11, 9, 11, 10, 12, 10, 13, 10, 13, 9, 11, 9, 11, 9, 12, 9, 11, 9, 10, 9, 12, 9, 11, 9, 9, 9, 10, 8, 10, 9, 11, 9, 10, 9, 10, 9, + 10, 9, 10, 9, 11, 8, 10, 9, 10, 9, 10, 9, 11, 9, 10, 8, 10, 8, 10, 9, 7, 3, 4, 5, 5, 6, 7, 7, 7, 8, 8, 9, 9, 9, 9, 10, 10, 11, 11, 11, + 10, 11, 12, 11, 12, 12, 12, 12, 13, 13, 13, 14, 12, 14, 13, 16, 14, 16, 13, 15, 14, 13, 15, 14, 15, 14, 15, 14, 14, 0, 14, 15, 14, 0, + 14, 0, 0, 0, 0, 0, 0, 0, 15, 0, 15, 0, 0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 13, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 15, 10 }; + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(256, tree.read(createBitInputStream(0x00, 0x00))); // 000x xxxx xxxx xxxx + assertEquals(0, tree.read(createBitInputStream(0x20, 0x00))); // 0010 xxxx xxxx xxxx + assertEquals(257, tree.read(createBitInputStream(0x30, 0x00))); // 0011 xxxx xxxx xxxx + assertEquals(258, tree.read(createBitInputStream(0x40, 0x00))); // 0100 0xxx xxxx xxxx + assertEquals(259, tree.read(createBitInputStream(0x48, 0x00))); // 0100 1xxx xxxx xxxx + assertEquals(32, tree.read(createBitInputStream(0x50, 0x00))); // 0101 00xx xxxx xxxx + assertEquals(260, tree.read(createBitInputStream(0x54, 0x00))); // 0101 01xx xxxx xxxx + + assertEquals(226, tree.read(createBitInputStream(0xbd, 0x00))); // 1011 1101 xxxx xxxx + assertEquals(240, tree.read(createBitInputStream(0xbe, 0x00))); // 1011 1110 xxxx xxxx + + assertEquals(163, tree.read(createBitInputStream(0xfb, 0xa0))); // 1111 1011 101x xxxx + assertEquals(165, tree.read(createBitInputStream(0xfb, 0xc0))); // 1111 1011 110x xxxx + + assertEquals(499, tree.read(createBitInputStream(0xff, 0xfa))); // 1111 1111 1111 101x + assertEquals(508, tree.read(createBitInputStream(0xff, 0xfc))); // 1111 1111 1111 110x + assertEquals(290, tree.read(createBitInputStream(0xff, 0xfe))); // 1111 1111 1111 1110 + assertEquals(292, tree.read(createBitInputStream(0xff, 0xff))); // 1111 1111 1111 1111 + } + + @Test + void testReadEof() throws Exception { + final int[] length = new int[] { 4, 2, 3, 0, 5, 5, 1 }; + // Value: 0 1 2 3 4 5 6 + + final BinaryTree tree = new BinaryTree(length); + + final BitInputStream in = createBitInputStream(0xfe); // 1111 1110 + + assertEquals(5, tree.read(in)); // 1111 1xxx + assertEquals(2, tree.read(in)); // 110x xxxx + assertEquals(-1, tree.read(in)); // EOF + } + + @Test + void testInvalidBitstream() throws Exception { + final int[] length = new int[] { 4, 2, 3, 0, 5, 0, 1 }; + // Value: 0 1 2 3 4 5 6 + + final BinaryTree tree = new BinaryTree(length); + + assertEquals(6, tree.read(createBitInputStream(0x00))); // 0xxx xxxx + assertEquals(1, tree.read(createBitInputStream(0x80))); // 10xx xxxx + assertEquals(2, tree.read(createBitInputStream(0xc0))); // 110x xxxx + assertEquals(0, tree.read(createBitInputStream(0xe0))); // 1110 xxxx + assertEquals(4, tree.read(createBitInputStream(0xf0))); // 1111 0xxx + + try { + assertEquals(5, tree.read(createBitInputStream(0xf8))); // 1111 1xxx + fail("Expected CompressorException for invalid bitstream"); + } catch (CompressorException e) { + assertEquals("Invalid bitstream. The node at index 62 is not defined.", e.getMessage()); + } + } + + @Test + void testCheckMaxDepth() throws Exception { + try { + new BinaryTree(1, 17); + fail("Expected CompressorException for depth > 16"); + } catch (CompressorException e) { + assertEquals("Tree depth must not be negative and not bigger than 16 but is 17", e.getMessage()); + } + } + + @Test + void testTooManyLeafNodes() throws Exception { + try { + new BinaryTree(0, 2, 1, 2, 2); + fail("Expected CompressorException for too many leaf nodes"); + } catch (CompressorException e) { + assertEquals("Tree contains too many leaf nodes for depth 2", e.getMessage()); + } + } + + @Test + void testNoLeafNodes() throws Exception { + try { + new BinaryTree(0, 0, 0, 0, 0); + fail("Expected CompressorException for no leaf nodes"); + } catch (CompressorException e) { + assertEquals("Tree contains no leaf nodes", e.getMessage()); + } + } + + private BitInputStream createBitInputStream(final int... data) throws IOException { + final byte[] bytes = new byte[data.length]; + for (int i = 0; i < data.length; i++) { + bytes[i] = (byte) data[i]; + } + + return new BitInputStream(new ByteArrayInputStream(bytes), ByteOrder.BIG_ENDIAN); + } +} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStreamTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStreamTest.java new file mode 100644 index 00000000000..633b33d6c30 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/Lh4CompressorInputStreamTest.java @@ -0,0 +1,70 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.compress.archivers.lha.LhaArchiveEntry; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class Lh4CompressorInputStreamTest extends AbstractTest { + @Test + void testConfiguration() throws IOException { + try (Lh4CompressorInputStream in = new Lh4CompressorInputStream(new ByteArrayInputStream(new byte[0]))) { + assertEquals(12, in.getDictionaryBits()); + assertEquals(4096, in.getDictionarySize()); + assertEquals(4, in.getDistanceBits()); + assertEquals(14, in.getMaxNumberOfDistanceCodes()); + assertEquals(256, in.getMaxMatchLength()); + assertEquals(510, in.getMaxNumberOfCommands()); + } + } + + @Test + void testDecompress() throws IOException { + // This file was created using LhA 1.38 on Amiga + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-amiga-l0-lh4.lha")).get()) { + // Check entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("lorem-ipsum.txt", entry.getName()); + assertEquals(144060, entry.getSize()); + assertEquals(41583, entry.getCompressedSize()); + assertEquals("-lh4-", entry.getCompressionMethod()); + assertEquals(0x8c8a, entry.getCrcValue()); + + // Decompress entry + assertTrue(archive.canReadEntryData(entry)); + final byte[] data = IOUtils.toByteArray(archive); + + assertEquals(144060, data.length); + assertEquals("\nLorem ipsum", new String(data, 0, 12, StandardCharsets.US_ASCII)); + } + } +} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStreamTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStreamTest.java new file mode 100644 index 00000000000..52d314ffedb --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/Lh5CompressorInputStreamTest.java @@ -0,0 +1,69 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.compress.archivers.lha.LhaArchiveEntry; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class Lh5CompressorInputStreamTest extends AbstractTest { + @Test + void testConfiguration() throws IOException { + try (Lh5CompressorInputStream in = new Lh5CompressorInputStream(new ByteArrayInputStream(new byte[0]))) { + assertEquals(8192, in.getDictionarySize()); + assertEquals(13, in.getDictionaryBits()); + assertEquals(4, in.getDistanceBits()); + assertEquals(14, in.getMaxNumberOfDistanceCodes()); + assertEquals(256, in.getMaxMatchLength()); + assertEquals(510, in.getMaxNumberOfCommands()); + } + } + + @Test + void testDecompress() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh5.lha")).get()) { + // Check entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("lorem-ipsum.txt", entry.getName()); + assertEquals(144060, entry.getSize()); + assertEquals(39999, entry.getCompressedSize()); + assertEquals("-lh5-", entry.getCompressionMethod()); + assertEquals(0x8c8a, entry.getCrcValue()); + + // Decompress entry + assertTrue(archive.canReadEntryData(entry)); + final byte[] data = IOUtils.toByteArray(archive); + + assertEquals(144060, data.length); + assertEquals("\nLorem ipsum", new String(data, 0, 12, StandardCharsets.US_ASCII)); + } + } +} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStreamTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStreamTest.java new file mode 100644 index 00000000000..a9645f7055f --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/Lh6CompressorInputStreamTest.java @@ -0,0 +1,69 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.compress.archivers.lha.LhaArchiveEntry; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class Lh6CompressorInputStreamTest extends AbstractTest { + @Test + void testConfiguration() throws IOException { + try (Lh6CompressorInputStream in = new Lh6CompressorInputStream(new ByteArrayInputStream(new byte[0]))) { + assertEquals(15, in.getDictionaryBits()); + assertEquals(32768, in.getDictionarySize()); + assertEquals(5, in.getDistanceBits()); + assertEquals(16, in.getMaxNumberOfDistanceCodes()); + assertEquals(256, in.getMaxMatchLength()); + assertEquals(510, in.getMaxNumberOfCommands()); + } + } + + @Test + void testDecompress() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream .builder().setInputStream(newInputStream("test-macos-l0-lh6.lha")).get()) { + // Check entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("lorem-ipsum.txt", entry.getName()); + assertEquals(144060, entry.getSize()); + assertEquals(38037, entry.getCompressedSize()); + assertEquals("-lh6-", entry.getCompressionMethod()); + assertEquals(0x8c8a, entry.getCrcValue()); + + // Decompress entry + assertTrue(archive.canReadEntryData(entry)); + final byte[] data = IOUtils.toByteArray(archive); + + assertEquals(144060, data.length); + assertEquals("\nLorem ipsum", new String(data, 0, 12, StandardCharsets.US_ASCII)); + } + } +} diff --git a/src/test/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStreamTest.java b/src/test/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStreamTest.java new file mode 100644 index 00000000000..47e767ba879 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/compressors/lha/Lh7CompressorInputStreamTest.java @@ -0,0 +1,69 @@ +/* + * 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.compressors.lha; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.compress.archivers.lha.LhaArchiveEntry; +import org.apache.commons.compress.archivers.lha.LhaArchiveInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class Lh7CompressorInputStreamTest extends AbstractTest { + @Test + void testConfiguration() throws IOException { + try (Lh7CompressorInputStream in = new Lh7CompressorInputStream(new ByteArrayInputStream(new byte[0]))) { + assertEquals(16, in.getDictionaryBits()); + assertEquals(65536, in.getDictionarySize()); + assertEquals(5, in.getDistanceBits()); + assertEquals(17, in.getMaxNumberOfDistanceCodes()); + assertEquals(256, in.getMaxMatchLength()); + assertEquals(510, in.getMaxNumberOfCommands()); + } + } + + @Test + void testDecompress() throws IOException { + try (LhaArchiveInputStream archive = LhaArchiveInputStream.builder().setInputStream(newInputStream("test-macos-l0-lh7.lha")).get()) { + // Check entry + final LhaArchiveEntry entry = archive.getNextEntry(); + assertNotNull(entry); + assertEquals("lorem-ipsum.txt", entry.getName()); + assertEquals(144060, entry.getSize()); + assertEquals(37401, entry.getCompressedSize()); + assertEquals("-lh7-", entry.getCompressionMethod()); + assertEquals(0x8c8a, entry.getCrcValue()); + + // Decompress entry + assertTrue(archive.canReadEntryData(entry)); + final byte[] data = IOUtils.toByteArray(archive); + + assertEquals(144060, data.length); + assertEquals("\nLorem ipsum", new String(data, 0, 12, StandardCharsets.US_ASCII)); + } + } +} diff --git a/src/test/java/org/apache/commons/compress/utils/CircularBufferTest.java b/src/test/java/org/apache/commons/compress/utils/CircularBufferTest.java new file mode 100644 index 00000000000..7c13ddf6cab --- /dev/null +++ b/src/test/java/org/apache/commons/compress/utils/CircularBufferTest.java @@ -0,0 +1,312 @@ +/* + * 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.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; + +class CircularBufferTest { + @Test + void testPutAndGet1() { + final int size = 16; + final CircularBuffer buffer = new CircularBuffer(size); + for (int i = 0; i < size / 2; i++) { + buffer.put(i); + } + + assertTrue(buffer.available(), "available"); + + for (int i = 0; i < size / 2; i++) { + assertEquals(i, buffer.get(), "buffer[" + i + "]"); + } + + assertEquals(-1, buffer.get()); + assertFalse(buffer.available(), "available"); + } + + @Test + void testPutAndGet2() { + final CircularBuffer buffer = new CircularBuffer(8); + + // Nothing to read + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + + // Write a byte and read it + buffer.put(0x01); + assertTrue(buffer.available()); + assertEquals(0x01, buffer.get()); + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + + // Write multiple bytes and read them + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + assertTrue(buffer.available()); + assertEquals(0x02, buffer.get()); + assertEquals(0x03, buffer.get()); + assertEquals(0x04, buffer.get()); + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + + @Test + void testPutAndGetWrappingAround() { + final CircularBuffer buffer = new CircularBuffer(4); + + // Nothing to read + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + + // Write two bytes and read them in a loop making the buffer wrap around several times + for (int i = 0; i < 8; i++) { + buffer.put(i * 2); + buffer.put(i * 2 + 1); + + assertTrue(buffer.available()); + assertEquals(i * 2, buffer.get()); + assertEquals(i * 2 + 1, buffer.get()); + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + } + + @Test + void testPutOverflow() { + final CircularBuffer buffer = new CircularBuffer(4); + + // Write more bytes than the buffer can hold + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + + try { + buffer.put(0x05); + fail("Expected IllegalStateException for buffer overflow"); + } catch (IllegalStateException e) { + assertEquals("Buffer overflow: Cannot write to a full buffer", e.getMessage()); + } + } + + @Test + void testCopy1() { + final CircularBuffer buffer = new CircularBuffer(16); + + buffer.put(1); + buffer.put(2); + buffer.get(); + buffer.get(); + + // copy uninitialized data + buffer.copy(6, 8); + + for (int i = 2; i < 6; i++) { + assertEquals(0, buffer.get(), "buffer[" + i + "]"); + } + assertEquals(1, buffer.get(), "buffer[" + 6 + "]"); + assertEquals(2, buffer.get(), "buffer[" + 7 + "]"); + assertEquals(0, buffer.get(), "buffer[" + 8 + "]"); + assertEquals(0, buffer.get(), "buffer[" + 9 + "]"); + + for (int i = 10; i < 14; i++) { + buffer.put(i); + buffer.get(); + } + + assertFalse(buffer.available(), "available"); + + // copy data and wrap + buffer.copy(2, 8); + + for (int i = 14; i < 18; i++) { + assertEquals(i % 2 == 0 ? 12 : 13, buffer.get(), "buffer[" + i + "]"); + } + } + + @Test + void testCopy2() { + final CircularBuffer buffer = new CircularBuffer(16); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + + buffer.copy(2, 2); // Copy last two bytes (0x03, 0x04) + + assertEquals(0x01, buffer.get()); + assertEquals(0x02, buffer.get()); + assertEquals(0x03, buffer.get()); + assertEquals(0x04, buffer.get()); + assertEquals(0x03, buffer.get()); + assertEquals(0x04, buffer.get()); + + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + + @Test + void testCopy3() { + final CircularBuffer buffer = new CircularBuffer(16); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + + buffer.copy(4, 2); // Copy first two bytes (0x01, 0x02) + + assertEquals(0x01, buffer.get()); + assertEquals(0x02, buffer.get()); + assertEquals(0x03, buffer.get()); + assertEquals(0x04, buffer.get()); + assertEquals(0x01, buffer.get()); // Copied byte + assertEquals(0x02, buffer.get()); // Copied byte + + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + + @Test + void testCopy4() { + final CircularBuffer buffer = new CircularBuffer(6); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + buffer.put(0x05); + buffer.put(0x06); + + // Read four bytes to make space + assertEquals(0x01, buffer.get()); + assertEquals(0x02, buffer.get()); + assertEquals(0x03, buffer.get()); + assertEquals(0x04, buffer.get()); + + // Write two more bytes and making the buffer wrap around + buffer.put(0x07); + buffer.put(0x08); + + buffer.copy(3, 2); // Copy two bytes from 3 bytes ago (0x06, 0x07) where the buffer wraps around + + // Read rest of the buffer + assertEquals(0x05, buffer.get()); + assertEquals(0x06, buffer.get()); + assertEquals(0x07, buffer.get()); + assertEquals(0x08, buffer.get()); + assertEquals(0x06, buffer.get()); // Copied byte + assertEquals(0x07, buffer.get()); // Copied byte + + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + + @Test + void testCopyRunLengthEncoding1() { + final CircularBuffer buffer = new CircularBuffer(16); + + // Write two bytes + buffer.put(0x01); + buffer.put(0x02); + + buffer.copy(1, 8); // Copy last byte (0x02) eight times + + // Read the buffer + assertEquals(0x01, buffer.get()); + assertEquals(0x02, buffer.get()); + assertEquals(0x02, buffer.get()); // Copied byte 1 + assertEquals(0x02, buffer.get()); // Copied byte 2 + assertEquals(0x02, buffer.get()); // Copied byte 3 + assertEquals(0x02, buffer.get()); // Copied byte 4 + assertEquals(0x02, buffer.get()); // Copied byte 5 + assertEquals(0x02, buffer.get()); // Copied byte 6 + assertEquals(0x02, buffer.get()); // Copied byte 7 + assertEquals(0x02, buffer.get()); // Copied byte 8 + + assertFalse(buffer.available()); + assertEquals(-1, buffer.get()); + } + + @Test + void testCopyDistanceInvalid() { + final CircularBuffer buffer = new CircularBuffer(4); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + + try { + buffer.copy(0, 2); // Try to copy from distance 0 + fail("Expected IllegalArgumentException for invalid distance"); + } catch (IllegalArgumentException e) { + assertEquals("Distance must be at least 1", e.getMessage()); + } + } + + @Test + void testCopyDistanceExceedingBufferSize() { + final CircularBuffer buffer = new CircularBuffer(4); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + + try { + buffer.copy(5, 2); // Try to copy from a distance that is bigger than the buffer size + fail("Expected IllegalArgumentException for distance exceeding buffer size"); + } catch (IllegalArgumentException e) { + assertEquals("Distance exceeds buffer size", e.getMessage()); + } + } + + @Test + void testCopyCausingBufferOverflow() { + final CircularBuffer buffer = new CircularBuffer(4); + + // Write some bytes + buffer.put(0x01); + buffer.put(0x02); + buffer.put(0x03); + buffer.put(0x04); + + // Read some bytes to make space + assertEquals(0x01, buffer.get()); + assertEquals(0x02, buffer.get()); + + try { + buffer.copy(4, 4); // Copying 4 bytes and write to the buffer that will be full during copy + fail("Expected IllegalStateException for buffer overflow during copy"); + } catch (IllegalStateException e) { + assertEquals("Buffer overflow: Cannot write to a full buffer", e.getMessage()); + } + } +} diff --git a/src/test/resources/bla.lha b/src/test/resources/bla.lha new file mode 100644 index 00000000000..2add0854a6f Binary files /dev/null and b/src/test/resources/bla.lha differ diff --git a/src/test/resources/test-amiga-l0-lh4.lha b/src/test/resources/test-amiga-l0-lh4.lha new file mode 100644 index 00000000000..2231d7d1d4f Binary files /dev/null and b/src/test/resources/test-amiga-l0-lh4.lha differ diff --git a/src/test/resources/test-amiga-l2.lha b/src/test/resources/test-amiga-l2.lha new file mode 100644 index 00000000000..b38bb7c7ee2 Binary files /dev/null and b/src/test/resources/test-amiga-l2.lha differ diff --git a/src/test/resources/test-macos-l0-lh5-rle.lha b/src/test/resources/test-macos-l0-lh5-rle.lha new file mode 100644 index 00000000000..2add0854a6f Binary files /dev/null and b/src/test/resources/test-macos-l0-lh5-rle.lha differ diff --git a/src/test/resources/test-macos-l0-lh5.lha b/src/test/resources/test-macos-l0-lh5.lha new file mode 100644 index 00000000000..7ea1f45dbbe Binary files /dev/null and b/src/test/resources/test-macos-l0-lh5.lha differ diff --git a/src/test/resources/test-macos-l0-lh6.lha b/src/test/resources/test-macos-l0-lh6.lha new file mode 100644 index 00000000000..123cd9d390b Binary files /dev/null and b/src/test/resources/test-macos-l0-lh6.lha differ diff --git a/src/test/resources/test-macos-l0-lh7.lha b/src/test/resources/test-macos-l0-lh7.lha new file mode 100644 index 00000000000..cc14cb70d1d Binary files /dev/null and b/src/test/resources/test-macos-l0-lh7.lha differ diff --git a/src/test/resources/test-macos-l0.lha b/src/test/resources/test-macos-l0.lha new file mode 100644 index 00000000000..c1867b93ac0 Binary files /dev/null and b/src/test/resources/test-macos-l0.lha differ diff --git a/src/test/resources/test-macos-l1.lha b/src/test/resources/test-macos-l1.lha new file mode 100644 index 00000000000..95401c661d4 Binary files /dev/null and b/src/test/resources/test-macos-l1.lha differ diff --git a/src/test/resources/test-macos-l2.lha b/src/test/resources/test-macos-l2.lha new file mode 100644 index 00000000000..8ae5c86d972 Binary files /dev/null and b/src/test/resources/test-macos-l2.lha differ diff --git a/src/test/resources/test-msdos-l0-lz4.lzs b/src/test/resources/test-msdos-l0-lz4.lzs new file mode 100644 index 00000000000..9eab0921ff3 Binary files /dev/null and b/src/test/resources/test-msdos-l0-lz4.lzs differ diff --git a/src/test/resources/test-msdos-l0.lha b/src/test/resources/test-msdos-l0.lha new file mode 100644 index 00000000000..175389c9616 Binary files /dev/null and b/src/test/resources/test-msdos-l0.lha differ diff --git a/src/test/resources/test-msdos-l1.lha b/src/test/resources/test-msdos-l1.lha new file mode 100644 index 00000000000..b325b22190c Binary files /dev/null and b/src/test/resources/test-msdos-l1.lha differ diff --git a/src/test/resources/test-msdos-l2-attrib.lha b/src/test/resources/test-msdos-l2-attrib.lha new file mode 100644 index 00000000000..bc73c2e543e Binary files /dev/null and b/src/test/resources/test-msdos-l2-attrib.lha differ diff --git a/src/test/resources/test-msdos-l2.lha b/src/test/resources/test-msdos-l2.lha new file mode 100644 index 00000000000..231c6f74375 Binary files /dev/null and b/src/test/resources/test-msdos-l2.lha differ