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