RawTiffDetectorFuzzTest.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.detect.image;

import static org.junit.jupiter.api.Assertions.fail;

import java.util.Locale;
import java.util.Random;

import org.junit.jupiter.api.Test;

import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;

/**
 * Randomized boundary test for {@link RawTiffDetector}'s directory walk.
 * <p>
 * The detector runs ahead of every parser, and {@code Detector.detect} declares
 * only {@link java.io.IOException}: anything else it throws aborts detection for
 * the document and the remaining detectors never run. So the invariant is simply
 * that no throwable escapes, whatever the directories say.
 * <p>
 * Inputs are well-formed TIFF and BigTIFF headers whose offsets, counts and
 * entry values are drawn from the arithmetic boundaries -- 0, the 32 and 64 bit
 * maxima, the prefix limit, and their neighbours -- since that is where the
 * offset handling goes wrong rather than in random bytes. The seed is random per
 * run and reported on failure.
 * <p>
 * Each input goes through both entry points: the in-memory one, and the stream
 * one, which is the only way to reach the chunked reads in {@code Prefix.ensure}
 * -- the arithmetic this guards. Trials are cheap but they saturate: measured
 * against this generator, 2000 reaches the same branches 20000 does.
 */
public class RawTiffDetectorFuzzTest {

    private static final int TRIALS = 2000;

    /**
     * Offsets and values worth trying: adding an entry size to one of the large
     * ones overflows, which is the arithmetic under test.
     */
    private static final long[] BOUNDARIES = {
            0L, 1L, 8L, 16L, 0xFFFFL, 0x7FFFFFFFL, 0x80000000L, 0xFFFFFFFFL, 0x100000000L,
            Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 7, Long.MAX_VALUE - 8,
            Long.MAX_VALUE - 20, Long.MIN_VALUE, -1L,
            RawTiffDetector.MAX_PREFIX_LENGTH, RawTiffDetector.MAX_PREFIX_LENGTH - 1,
            RawTiffDetector.MAX_PREFIX_LENGTH + 1};

    private static final int[] TAGS =
            {0x00FE, 0x0102, 0x0103, 0x0106, 0x010F, 0x014A, 0xC612};
    private static final int[] TYPES = {2, 3, 4, 13, 16, 18};

    @Test
    public void testBoundaryOffsets() {
        long seed = new Random().nextLong();
        Random rng = new Random(seed);
        for (int trial = 0; trial < TRIALS; trial++) {
            byte[] tiff = randomTiff(rng);
            try {
                RawTiffDetector.detect(tiff, tiff.length);
                try (TikaInputStream tis = TikaInputStream.get(tiff)) {
                    new RawTiffDetector().detect(tis, new Metadata(), new ParseContext());
                }
            } catch (Throwable t) {
                fail("detect threw " + t + " -- seed=" + seed + " trial=" + trial
                        + " bytes=" + hex(tiff), t);
            }
        }
    }

    private static byte[] randomTiff(Random rng) {
        boolean bigTiff = rng.nextInt(4) != 0;
        byte[] b = new byte[24 + rng.nextInt(400)];
        rng.nextBytes(b);
        b[0] = 'I';
        b[1] = 'I';
        put(b, 2, bigTiff ? 43 : 42, 2);
        int header;
        if (bigTiff) {
            put(b, 4, 8, 2);
            put(b, 6, 0, 2);
            put(b, 8, boundary(rng), 8);
            header = 16;
        } else {
            put(b, 4, boundary(rng), 4);
            header = 8;
        }
        if (rng.nextBoolean()) {
            //also point the header at a directory that is really there, so the
            //entry values get walked rather than rejected at the first offset
            put(b, bigTiff ? 8 : 4, header, bigTiff ? 8 : 4);
            fillDirectory(b, header, bigTiff, rng);
        }
        return b;
    }

    private static void fillDirectory(byte[] b, int at, boolean bigTiff, Random rng) {
        int countSize = bigTiff ? 8 : 2;
        int entrySize = bigTiff ? 20 : 12;
        int offsetSize = bigTiff ? 8 : 4;
        int numEntries = rng.nextInt(6);
        put(b, at, numEntries, countSize);
        int p = at + countSize;
        for (int i = 0; i < numEntries && p + entrySize <= b.length; i++) {
            put(b, p, TAGS[rng.nextInt(TAGS.length)], 2);
            put(b, p + 2, TYPES[rng.nextInt(TYPES.length)], 2);
            put(b, p + 4, boundary(rng), offsetSize);
            put(b, p + 4 + offsetSize, boundary(rng), offsetSize);
            p += entrySize;
        }
        if (p + offsetSize <= b.length) {
            put(b, p, boundary(rng), offsetSize);
        }
    }

    private static long boundary(Random rng) {
        return BOUNDARIES[rng.nextInt(BOUNDARIES.length)];
    }

    private static void put(byte[] b, int off, long value, int width) {
        for (int i = 0; i < width && off + i < b.length; i++) {
            b[off + i] = (byte) ((value >>> (8 * i)) & 0xFF);
        }
    }

    private static String hex(byte[] b) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < Math.min(b.length, 64); i++) {
            sb.append(String.format(Locale.ROOT, "%02X", b[i]));
        }
        return sb.toString();
    }
}