001/*
002 *  Licensed to the Apache Software Foundation (ASF) under one or more
003 *  contributor license agreements.  See the NOTICE file distributed with
004 *  this work for additional information regarding copyright ownership.
005 *  The ASF licenses this file to You under the Apache License, Version 2.0
006 *  (the "License"); you may not use this file except in compliance with
007 *  the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 *  Unless required by applicable law or agreed to in writing, software
012 *  distributed under the License is distributed on an "AS IS" BASIS,
013 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 *  See the License for the specific language governing permissions and
015 *  limitations under the License.
016 */
017package org.apache.commons.compress.archivers.arj;
018
019import java.io.ByteArrayInputStream;
020import java.io.ByteArrayOutputStream;
021import java.io.DataInputStream;
022import java.io.EOFException;
023import java.io.IOException;
024import java.io.InputStream;
025import java.util.ArrayList;
026import java.util.zip.CRC32;
027
028import org.apache.commons.compress.archivers.ArchiveEntry;
029import org.apache.commons.compress.archivers.ArchiveException;
030import org.apache.commons.compress.archivers.ArchiveInputStream;
031import org.apache.commons.compress.utils.IOUtils;
032import org.apache.commons.io.input.BoundedInputStream;
033import org.apache.commons.io.input.ChecksumInputStream;
034
035/**
036 * Implements the "arj" archive format as an InputStream.
037 * <ul>
038 * <li><a href="https://github.com/FarGroup/FarManager/blob/master/plugins/multiarc/arc.doc/arj.txt">Reference 1</a></li>
039 * <li><a href="http://www.fileformat.info/format/arj/corion.htm">Reference 2</a></li>
040 * </ul>
041 *
042 * @NotThreadSafe
043 * @since 1.6
044 */
045public class ArjArchiveInputStream extends ArchiveInputStream<ArjArchiveEntry> {
046
047    private static final String ENCODING_NAME = "CP437";
048    private static final int ARJ_MAGIC_1 = 0x60;
049    private static final int ARJ_MAGIC_2 = 0xEA;
050
051    /**
052     * Checks if the signature matches what is expected for an arj file.
053     *
054     * @param signature the bytes to check
055     * @param length    the number of bytes to check
056     * @return true, if this stream is an arj archive stream, false otherwise
057     */
058    public static boolean matches(final byte[] signature, final int length) {
059        return length >= 2 && (0xff & signature[0]) == ARJ_MAGIC_1 && (0xff & signature[1]) == ARJ_MAGIC_2;
060    }
061
062    private final DataInputStream dis;
063    private final MainHeader mainHeader;
064    private LocalFileHeader currentLocalFileHeader;
065    private InputStream currentInputStream;
066
067    /**
068     * Constructs the ArjInputStream, taking ownership of the inputStream that is passed in, and using the CP437 character encoding.
069     *
070     * @param inputStream the underlying stream, whose ownership is taken
071     * @throws ArchiveException if an exception occurs while reading
072     */
073    public ArjArchiveInputStream(final InputStream inputStream) throws ArchiveException {
074        this(inputStream, ENCODING_NAME);
075    }
076
077    /**
078     * Constructs the ArjInputStream, taking ownership of the inputStream that is passed in.
079     *
080     * @param inputStream the underlying stream, whose ownership is taken
081     * @param charsetName the charset used for file names and comments in the archive. May be {@code null} to use the platform default.
082     * @throws ArchiveException if an exception occurs while reading
083     */
084    public ArjArchiveInputStream(final InputStream inputStream, final String charsetName) throws ArchiveException {
085        super(inputStream, charsetName);
086        in = dis = new DataInputStream(inputStream);
087        try {
088            mainHeader = readMainHeader();
089            if ((mainHeader.arjFlags & MainHeader.Flags.GARBLED) != 0) {
090                throw new ArchiveException("Encrypted ARJ files are unsupported");
091            }
092            if ((mainHeader.arjFlags & MainHeader.Flags.VOLUME) != 0) {
093                throw new ArchiveException("Multi-volume ARJ files are unsupported");
094            }
095        } catch (final IOException ioException) {
096            throw new ArchiveException(ioException.getMessage(), ioException);
097        }
098    }
099
100    @Override
101    public boolean canReadEntryData(final ArchiveEntry ae) {
102        return ae instanceof ArjArchiveEntry && ((ArjArchiveEntry) ae).getMethod() == LocalFileHeader.Methods.STORED;
103    }
104
105    @Override
106    public void close() throws IOException {
107        dis.close();
108    }
109
110    /**
111     * Gets the archive's comment.
112     *
113     * @return the archive's comment
114     */
115    public String getArchiveComment() {
116        return mainHeader.comment;
117    }
118
119    /**
120     * Gets the archive's recorded name.
121     *
122     * @return the archive's name
123     */
124    public String getArchiveName() {
125        return mainHeader.name;
126    }
127
128    @Override
129    public ArjArchiveEntry getNextEntry() throws IOException {
130        if (currentInputStream != null) {
131            // return value ignored as IOUtils.skip ensures the stream is drained completely
132            final InputStream input = currentInputStream;
133            org.apache.commons.io.IOUtils.skip(input, Long.MAX_VALUE);
134            currentInputStream.close();
135            currentLocalFileHeader = null;
136            currentInputStream = null;
137        }
138
139        currentLocalFileHeader = readLocalFileHeader();
140        if (currentLocalFileHeader != null) {
141            // @formatter:off
142            currentInputStream = BoundedInputStream.builder()
143                    .setInputStream(dis)
144                    .setMaxCount(currentLocalFileHeader.compressedSize)
145                    .setPropagateClose(false)
146                    .get();
147            // @formatter:on
148            if (currentLocalFileHeader.method == LocalFileHeader.Methods.STORED) {
149                // @formatter:off
150                currentInputStream = ChecksumInputStream.builder()
151                        .setChecksum(new CRC32())
152                        .setInputStream(currentInputStream)
153                        .setCountThreshold(currentLocalFileHeader.originalSize)
154                        .setExpectedChecksumValue(currentLocalFileHeader.originalCrc32)
155                        .get();
156                // @formatter:on
157            }
158            return new ArjArchiveEntry(currentLocalFileHeader);
159        }
160        currentInputStream = null;
161        return null;
162    }
163
164    @Override
165    public int read(final byte[] b, final int off, final int len) throws IOException {
166        if (len == 0) {
167            return 0;
168        }
169        if (currentLocalFileHeader == null) {
170            throw new IllegalStateException("No current arj entry");
171        }
172        if (currentLocalFileHeader.method != LocalFileHeader.Methods.STORED) {
173            throw new IOException("Unsupported compression method " + currentLocalFileHeader.method);
174        }
175        return currentInputStream.read(b, off, len);
176    }
177
178    private int read16(final DataInputStream dataIn) throws IOException {
179        final int value = dataIn.readUnsignedShort();
180        count(2);
181        return Integer.reverseBytes(value) >>> 16;
182    }
183
184    private int read32(final DataInputStream dataIn) throws IOException {
185        final int value = dataIn.readInt();
186        count(4);
187        return Integer.reverseBytes(value);
188    }
189
190    private int read8(final DataInputStream dataIn) throws IOException {
191        final int value = dataIn.readUnsignedByte();
192        count(1);
193        return value;
194    }
195
196    private void readExtraData(final int firstHeaderSize, final DataInputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException {
197        if (firstHeaderSize >= 33) {
198            localFileHeader.extendedFilePosition = read32(firstHeader);
199            if (firstHeaderSize >= 45) {
200                localFileHeader.dateTimeAccessed = read32(firstHeader);
201                localFileHeader.dateTimeCreated = read32(firstHeader);
202                localFileHeader.originalSizeEvenForVolumes = read32(firstHeader);
203                pushedBackBytes(12);
204            }
205            pushedBackBytes(4);
206        }
207    }
208
209    private byte[] readHeader() throws IOException {
210        boolean found = false;
211        byte[] basicHeaderBytes = null;
212        do {
213            int first;
214            int second = read8(dis);
215            do {
216                first = second;
217                second = read8(dis);
218            } while (first != ARJ_MAGIC_1 && second != ARJ_MAGIC_2);
219            final int basicHeaderSize = read16(dis);
220            if (basicHeaderSize == 0) {
221                // end of archive
222                return null;
223            }
224            if (basicHeaderSize <= 2600) {
225                basicHeaderBytes = readRange(dis, basicHeaderSize);
226                final long basicHeaderCrc32 = read32(dis) & 0xFFFFFFFFL;
227                final CRC32 crc32 = new CRC32();
228                crc32.update(basicHeaderBytes);
229                if (basicHeaderCrc32 == crc32.getValue()) {
230                    found = true;
231                }
232            }
233        } while (!found);
234        return basicHeaderBytes;
235    }
236
237    private LocalFileHeader readLocalFileHeader() throws IOException {
238        final byte[] basicHeaderBytes = readHeader();
239        if (basicHeaderBytes == null) {
240            return null;
241        }
242        try (DataInputStream basicHeader = new DataInputStream(new ByteArrayInputStream(basicHeaderBytes))) {
243
244            final int firstHeaderSize = basicHeader.readUnsignedByte();
245            final byte[] firstHeaderBytes = readRange(basicHeader, firstHeaderSize - 1);
246            pushedBackBytes(firstHeaderBytes.length);
247            try (DataInputStream firstHeader = new DataInputStream(new ByteArrayInputStream(firstHeaderBytes))) {
248
249                final LocalFileHeader localFileHeader = new LocalFileHeader();
250                localFileHeader.archiverVersionNumber = firstHeader.readUnsignedByte();
251                localFileHeader.minVersionToExtract = firstHeader.readUnsignedByte();
252                localFileHeader.hostOS = firstHeader.readUnsignedByte();
253                localFileHeader.arjFlags = firstHeader.readUnsignedByte();
254                localFileHeader.method = firstHeader.readUnsignedByte();
255                localFileHeader.fileType = firstHeader.readUnsignedByte();
256                localFileHeader.reserved = firstHeader.readUnsignedByte();
257                localFileHeader.dateTimeModified = read32(firstHeader);
258                localFileHeader.compressedSize = 0xffffFFFFL & read32(firstHeader);
259                localFileHeader.originalSize = 0xffffFFFFL & read32(firstHeader);
260                localFileHeader.originalCrc32 = 0xffffFFFFL & read32(firstHeader);
261                localFileHeader.fileSpecPosition = read16(firstHeader);
262                localFileHeader.fileAccessMode = read16(firstHeader);
263                pushedBackBytes(20);
264                localFileHeader.firstChapter = firstHeader.readUnsignedByte();
265                localFileHeader.lastChapter = firstHeader.readUnsignedByte();
266
267                readExtraData(firstHeaderSize, firstHeader, localFileHeader);
268
269                localFileHeader.name = readString(basicHeader);
270                localFileHeader.comment = readString(basicHeader);
271
272                final ArrayList<byte[]> extendedHeaders = new ArrayList<>();
273                int extendedHeaderSize;
274                while ((extendedHeaderSize = read16(dis)) > 0) {
275                    final byte[] extendedHeaderBytes = readRange(dis, extendedHeaderSize);
276                    final long extendedHeaderCrc32 = 0xffffFFFFL & read32(dis);
277                    final CRC32 crc32 = new CRC32();
278                    crc32.update(extendedHeaderBytes);
279                    if (extendedHeaderCrc32 != crc32.getValue()) {
280                        throw new IOException("Extended header CRC32 verification failure");
281                    }
282                    extendedHeaders.add(extendedHeaderBytes);
283                }
284                localFileHeader.extendedHeaders = extendedHeaders.toArray(new byte[0][]);
285
286                return localFileHeader;
287            }
288        }
289    }
290
291    private MainHeader readMainHeader() throws IOException {
292        final byte[] basicHeaderBytes = readHeader();
293        if (basicHeaderBytes == null) {
294            throw new IOException("Archive ends without any headers");
295        }
296        final DataInputStream basicHeader = new DataInputStream(new ByteArrayInputStream(basicHeaderBytes));
297
298        final int firstHeaderSize = basicHeader.readUnsignedByte();
299        final byte[] firstHeaderBytes = readRange(basicHeader, firstHeaderSize - 1);
300        pushedBackBytes(firstHeaderBytes.length);
301
302        final DataInputStream firstHeader = new DataInputStream(new ByteArrayInputStream(firstHeaderBytes));
303
304        final MainHeader hdr = new MainHeader();
305        hdr.archiverVersionNumber = firstHeader.readUnsignedByte();
306        hdr.minVersionToExtract = firstHeader.readUnsignedByte();
307        hdr.hostOS = firstHeader.readUnsignedByte();
308        hdr.arjFlags = firstHeader.readUnsignedByte();
309        hdr.securityVersion = firstHeader.readUnsignedByte();
310        hdr.fileType = firstHeader.readUnsignedByte();
311        hdr.reserved = firstHeader.readUnsignedByte();
312        hdr.dateTimeCreated = read32(firstHeader);
313        hdr.dateTimeModified = read32(firstHeader);
314        hdr.archiveSize = 0xffffFFFFL & read32(firstHeader);
315        hdr.securityEnvelopeFilePosition = read32(firstHeader);
316        hdr.fileSpecPosition = read16(firstHeader);
317        hdr.securityEnvelopeLength = read16(firstHeader);
318        pushedBackBytes(20); // count has already counted them via readRange
319        hdr.encryptionVersion = firstHeader.readUnsignedByte();
320        hdr.lastChapter = firstHeader.readUnsignedByte();
321
322        if (firstHeaderSize >= 33) {
323            hdr.arjProtectionFactor = firstHeader.readUnsignedByte();
324            hdr.arjFlags2 = firstHeader.readUnsignedByte();
325            firstHeader.readUnsignedByte();
326            firstHeader.readUnsignedByte();
327        }
328
329        hdr.name = readString(basicHeader);
330        hdr.comment = readString(basicHeader);
331
332        final int extendedHeaderSize = read16(dis);
333        if (extendedHeaderSize > 0) {
334            hdr.extendedHeaderBytes = readRange(dis, extendedHeaderSize);
335            final long extendedHeaderCrc32 = 0xffffFFFFL & read32(dis);
336            final CRC32 crc32 = new CRC32();
337            crc32.update(hdr.extendedHeaderBytes);
338            if (extendedHeaderCrc32 != crc32.getValue()) {
339                throw new IOException("Extended header CRC32 verification failure");
340            }
341        }
342
343        return hdr;
344    }
345
346    private byte[] readRange(final InputStream in, final int len) throws IOException {
347        final byte[] b = IOUtils.readRange(in, len);
348        count(b.length);
349        if (b.length < len) {
350            throw new EOFException();
351        }
352        return b;
353    }
354
355    private String readString(final DataInputStream dataIn) throws IOException {
356        try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
357            int nextByte;
358            while ((nextByte = dataIn.readUnsignedByte()) != 0) {
359                buffer.write(nextByte);
360            }
361            return buffer.toString(getCharset().name());
362        }
363    }
364}