TestChmMalformed.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://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.tika.parser.microsoft.chm;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

import org.apache.tika.exception.TikaException;

/**
 * Characterization harness for CHM parsing on malformed / hostile input.
 * <p>
 * CHM is a binary format read from untrusted bytes. The contract for any Tika parser is that
 * bad input surfaces as a {@link TikaException} (here {@link ChmParsingException}), never as a
 * raw {@link RuntimeException} ({@code IllegalArgumentException}, {@code NegativeArraySizeException},
 * {@code ArrayIndexOutOfBoundsException}, {@code NullPointerException}) and never as an
 * {@code OutOfMemoryError}. These tests assert that target. A failing test here documents a spot
 * where malformed input currently escapes as the wrong throwable; the hardening turns it green,
 * while {@code TestChmExtraction} guards that real CHM files still parse.
 */
public class TestChmMalformed {

    // ------------------------------------------------------------------
    // ChmCommons.copyOfRange: the shared offset chokepoint. Malformed CHM
    // offsets/lengths reach it as out-of-range (from, to) after being cast
    // from parsed uint32/uint64 fields. It already declares throws TikaException.
    // ------------------------------------------------------------------

    @Test
    public void testCopyOfRangeToBeyondLength() {
        byte[] data = new byte[16];
        assertThrows(TikaException.class, () -> ChmCommons.copyOfRange(data, 0, 32));
    }

    @Test
    public void testCopyOfRangeFromGreaterThanTo() {
        byte[] data = new byte[16];
        assertThrows(TikaException.class, () -> ChmCommons.copyOfRange(data, 12, 4));
    }

    @Test
    public void testCopyOfRangeNegativeFrom() {
        // a wrapped long->int offset can land negative
        byte[] data = new byte[16];
        assertThrows(TikaException.class, () -> ChmCommons.copyOfRange(data, -1, 4));
    }

    @Test
    public void testCopyOfRangeFromBeyondLength() {
        byte[] data = new byte[16];
        assertThrows(TikaException.class, () -> ChmCommons.copyOfRange(data, 32, 40));
    }

    // ------------------------------------------------------------------
    // ChmExtractor end-to-end: the ctor parses ITSF/ITSP/directory eagerly.
    // ------------------------------------------------------------------

    @Test
    public void testTruncatedShorterThanItsfHeader() {
        // shorter than CHM_ITSF_V3_LEN, which the ctor immediately copyOfRange's out
        byte[] tiny = new byte[10];
        assertThrows(TikaException.class,
                () -> new ChmExtractor(new ByteArrayInputStream(tiny)));
    }

    @Test
    public void testEmptyInput() {
        assertThrows(TikaException.class,
                () -> new ChmExtractor(new ByteArrayInputStream(new byte[0])));
    }

    @Test
    public void testItsfLengthButAllZero() {
        // long enough for the first header copy, but not a valid ITSF signature
        byte[] zeros = new byte[512];
        assertThrows(TikaException.class,
                () -> new ChmExtractor(new ByteArrayInputStream(zeros)));
    }

    @Test
    public void testIOExceptionNotSwallowed() {
        // a stream that fails mid-read must surface, not leave a half-built extractor
        // that NPEs later in ChmParser
        InputStream broken = new InputStream() {
            @Override
            public int read() throws IOException {
                throw new IOException("boom");
            }
        };
        assertThrows(IOException.class, () -> new ChmExtractor(broken));
    }

    // ------------------------------------------------------------------
    // ChmLzxcResetTable.enumerateBlockAddresses allocates new long[blockCount] and
    // reads dataRemained/8 addresses. A block count that is huge (uint32 high bit)
    // or smaller than the trailing data must not become a huge/negative allocation
    // or write past the array.
    // ------------------------------------------------------------------

    @Test
    public void testResetTableHugeBlockCountIsCapped() {
        // 40-byte header, no address data; blockCount 0x80000000 is a large unsigned
        // value that must be capped rather than allocated in full or read as negative
        byte[] data = new byte[40];
        data[0] = 0x02;             // version = 2 (little-endian)
        data[7] = (byte) 0x80;      // blockCount = 0x80000000 = 2147483648 unsigned
        ChmLzxcResetTable table = new ChmLzxcResetTable();
        assertDoesNotThrow(() -> table.parse(data, table));
        assertTrue(table.getBlockAddress().length <= 5000);
    }

    @Test
    public void testResetTableBlockCountSmallerThanData() {
        // block count smaller than the trailing address data must not write past
        // the addresses array (previously an AIOOBE)
        byte[] data = new byte[56];  // 40-byte header + room for 2 addresses
        data[0] = 0x02;             // version = 2
        data[4] = 0x01;             // blockCount = 1
        ChmLzxcResetTable table = new ChmLzxcResetTable();
        assertDoesNotThrow(() -> table.parse(data, table));
        assertEquals(1, table.getBlockAddress().length);
    }

    // ------------------------------------------------------------------
    // ChmLzxBlock allocates content of (int) blockLength. A blockLength coming
    // from a crafted reset table that is >= Integer.MAX_VALUE overflows the int
    // (negative content length) and, before that, null-derefs in checkLzxBlock.
    // ------------------------------------------------------------------

    @Test
    public void testLzxBlockOversizeBlockLength() {
        // blockLength >= Integer.MAX_VALUE (also > MAX_CONTENT_SIZE)
        assertThrows(TikaException.class,
                () -> new ChmLzxBlock(0, new byte[16], 0x80000000L, null));
    }

    // ------------------------------------------------------------------
    // Entry name lengths are variable-length ENCINTs. A signed-byte comparison used
    // to cap the decoded value at 127 (corrupting longer names), and BigInteger-based
    // decoders grew without bound on a run of continuation bytes (an O(n^2) DoS).
    // ------------------------------------------------------------------

    @Test
    public void testDecodeEncintSingleByte() {
        assertEquals(0, ChmDirectoryListingSet.decodeEncint(new byte[] {0x00}, 0));
        assertEquals(5, ChmDirectoryListingSet.decodeEncint(new byte[] {0x05}, 0));
        assertEquals(127, ChmDirectoryListingSet.decodeEncint(new byte[] {0x7f}, 0));
    }

    @Test
    public void testDecodeEncintMultiByte() {
        // the high bit signals a continuation byte: 0x81 0x00 = 128, 0x81 0x48 = 200
        assertEquals(128, ChmDirectoryListingSet.decodeEncint(new byte[] {(byte) 0x81, 0x00}, 0));
        assertEquals(200, ChmDirectoryListingSet.decodeEncint(new byte[] {(byte) 0x81, 0x48}, 0));
        assertEquals(16384,
                ChmDirectoryListingSet.decodeEncint(new byte[] {(byte) 0x81, (byte) 0x80, 0x00}, 0));
    }

    @Test
    @Timeout(value = 30, unit = TimeUnit.SECONDS)
    public void testChmSectionGetEncintNotUnboundedBigInteger() throws Exception {
        // a section that is all continuation bytes must not accumulate an unbounded
        // BigInteger (previously O(n^2)); the bounded long version returns promptly
        byte[] hostile = new byte[1_000_000];
        Arrays.fill(hostile, (byte) 0xff);
        ChmSection section = new ChmSection(hostile);
        assertDoesNotThrow(section::getEncint);
    }
}