ICOParserTest.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.image;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Arrays;

import org.junit.jupiter.api.Test;
import org.xml.sax.helpers.DefaultHandler;

import org.apache.tika.TikaTest;
import org.apache.tika.exception.TikaException;
import org.apache.tika.io.EndianUtils;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Icon;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TIFF;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.DefaultParser;
import org.apache.tika.parser.ParseContext;

/**
 * testICO.ico holds 16 and 32 px BMP-encoded images plus a 256 px PNG-encoded
 * one, all 32 bpp; testICO_bmpOnly.ico holds 16 and 48 px BMP images;
 * testCUR.cur holds 16 px (hotspot 3,2) and 32 px (hotspot 7,5) cursors.
 * All were generated, not taken from a product.
 */
public class ICOParserTest extends TikaTest {

    @Test
    public void testIconWithPngEntry() throws Exception {
        Metadata metadata = parse("testICO.ico");
        assertEquals("image/vnd.microsoft.icon", metadata.get(HttpHeaders.CONTENT_TYPE));
        // the 256 px entry is stored as 0x0 in the directory; the size comes from the PNG header
        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertEquals(256, metadata.getInt(TIFF.IMAGE_LENGTH));
        // 32 bpp RGBA: 8 bits per sample, 4 samples
        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp", "256x256@32bpp png"},
                metadata.getValues(Icon.IMAGES));
        assertNull(metadata.get(Icon.HOTSPOT_X));
        assertNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    @Test
    public void testIconBmpOnly() throws Exception {
        Metadata metadata = parse("testICO_bmpOnly.ico");
        assertEquals(48, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertEquals(48, metadata.getInt(TIFF.IMAGE_LENGTH));
        assertEquals(2, metadata.getInt(Icon.IMAGE_COUNT));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "48x48@32bpp bmp"},
                metadata.getValues(Icon.IMAGES));
    }

    @Test
    public void testCursor() throws Exception {
        Metadata metadata = parse("testCUR.cur");
        assertEquals("image/x-win-bitmap", metadata.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertEquals(32, metadata.getInt(TIFF.IMAGE_LENGTH));
        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
        assertEquals(7, metadata.getInt(Icon.HOTSPOT_X));
        assertEquals(5, metadata.getInt(Icon.HOTSPOT_Y));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
                metadata.getValues(Icon.IMAGES));
    }

    /**
     * A cursor's directory entry carries the hotspot where an icon's carries
     * the bit count, so a cursor whose image header is cut has a hotspot but
     * no colour depth.
     */
    @Test
    public void testCursorWithUnreadableImage() throws Exception {
        byte[] cur = readTestResource("testCUR.cur");
        // cut ten bytes into the second image, the 32 px one
        Metadata metadata = parse(Arrays.copyOf(cur, imageOffset(cur, 1) + 10));
        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertNull(metadata.get(TIFF.BITS_PER_SAMPLE));
        assertEquals(7, metadata.getInt(Icon.HOTSPOT_X));
        assertEquals(5, metadata.getInt(Icon.HOTSPOT_Y));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32 unknown"},
                metadata.getValues(Icon.IMAGES));
        assertContains("1 of 2 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    @Test
    public void testAliasRoutesHere() throws Exception {
        Metadata metadata = new Metadata();
        metadata.set(HttpHeaders.CONTENT_TYPE, "image/x-icon");
        try (TikaInputStream tis = TikaInputStream.get(readTestResource("testICO.ico"))) {
            new DefaultParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
        }
        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
    }

    @Test
    public void testAutoDetect() throws Exception {
        Metadata ico = getXML("testICO.ico").metadata;
        assertEquals("image/vnd.microsoft.icon", ico.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(256, ico.getInt(TIFF.IMAGE_WIDTH));
        Metadata cur = getXML("testCUR.cur").metadata;
        assertEquals("image/x-win-bitmap", cur.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(7, cur.getInt(Icon.HOTSPOT_X));
    }

    /**
     * Only two of three images survive the cut; they are reported, the third
     * is a warning, not a failure.
     */
    @Test
    public void testTruncated() throws Exception {
        byte[] file = readTestResource("testICO.ico");
        // the third image, the PNG one, is cut, but its 29 byte header survives, and headers
        // are all this parser reads
        Metadata metadata = parse(Arrays.copyOf(file, imageOffset(file, 2) + 100));
        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp", "256x256@32bpp png"},
                metadata.getValues(Icon.IMAGES));
        assertNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));

        // the second image's header is cut: its directory entry still says 32x32, and the
        // third image lies beyond the end
        metadata = parse(Arrays.copyOf(file, imageOffset(file, 1) + 10));
        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp unknown"},
                metadata.getValues(Icon.IMAGES));
        // the second image's depth comes from the directory, so it is still 32 bpp
        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
        // one image cut, one beyond the end; the count is what was found, not what the header says
        assertContains("2 of 3 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
        assertEquals(2, metadata.getInt(Icon.IMAGE_COUNT));

        // cut inside the directory itself
        metadata = parse(Arrays.copyOf(file, 30));
        assertNull(metadata.get(TIFF.IMAGE_WIDTH));
        assertEquals(0, metadata.getInt(Icon.IMAGE_COUNT));
        assertContains("3 of 3 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * A PNG signature alone is no PNG header: the first chunk has to be an
     * IHDR of 13 bytes.
     */
    @Test
    public void testPngWithoutIhdr() throws Exception {
        byte[] file = readTestResource("testICO.ico");
        // the 256 px image's first chunk: its length 8 bytes in, its type 12
        int png = imageOffset(file, 2);
        assertFallsBackToDirectory(patch(file, png + 12, 'X', 'X', 'X', 'X'),
                "256x256@32bpp unknown");
        assertFallsBackToDirectory(patch(file, png + 8, 0, 0, 0, 12), "256x256@32bpp unknown");
    }

    /**
     * Of two images of one size the deeper one is reported, wherever it stands.
     */
    @Test
    public void testDeeperImageWinsAtEqualSize() throws Exception {
        byte[] file = readTestResource("testICO_bmpOnly.ico");
        // the 16 px image's header is made 48 px and 24 bpp, ahead of the 48 px, 32 bpp one
        int header = imageOffset(file, 0);
        byte[] sameSize = patch(patch(patch(file, header + 4, 48), header + 8, 96), header + 14, 24);
        Metadata metadata = parse(sameSize);
        assertArrayEquals(new String[]{"48x48@24bpp bmp", "48x48@32bpp bmp"},
                metadata.getValues(Icon.IMAGES));
        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
    }

    /**
     * An image whose data lies outside the file is not listed at all; the
     * largest of the others is reported.
     */
    @Test
    public void testDirectoryFallback() throws Exception {
        byte[] file = readTestResource("testICO.ico");
        // the third entry's image offset (6 + 2 * 16 + 12) is sent past the end of the file
        Metadata metadata = parse(patch(file, 6 + 2 * 16 + 12, 0xff, 0xff, 0, 0));
        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
                metadata.getValues(Icon.IMAGES));
        assertContains("1 of 3 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * An OS/2 bitmap array is not an icon: it is detected as its own type and
     * never reaches this parser, which rejects it when called directly.
     */
    @Test
    public void testOs2BitmapArray() throws Exception {
        byte[] bitmapArray = new byte[64];
        bitmapArray[0] = 'B';
        bitmapArray[1] = 'A';
        bitmapArray[2] = 0x28;
        bitmapArray[6] = 0x2e;
        bitmapArray[14] = 'I';
        bitmapArray[15] = 'C';

        assertThrows(TikaException.class, () -> parse(bitmapArray));

        Metadata detected = new Metadata();
        try (TikaInputStream tis = TikaInputStream.get(bitmapArray)) {
            getXML(tis, AUTO_DETECT_PARSER, detected);
        }
        assertEquals("image/x-os2-graphics; charset=binary",
                detected.get(HttpHeaders.CONTENT_TYPE));
        assertNull(detected.get(TikaCoreProperties.CONTAINER_EXCEPTION));
    }

    /**
     * A header whose width or height is zero, negative or beyond any real icon
     * is unreadable: the directory values stand in.
     */
    @Test
    public void testHostileDimensions() throws Exception {
        byte[] file = readTestResource("testICO.ico");
        // the first image is a 16 px BMP, the third a 256 px PNG
        int bmpWidth = imageOffset(file, 0) + 4;
        int bmpHeight = imageOffset(file, 0) + 8;
        int pngWidth = imageOffset(file, 2) + 16;
        int pngHeight = imageOffset(file, 2) + 20;

        // little endian -1 and Integer.MIN_VALUE
        assertFallsBackToDirectory(patch(file, bmpWidth, 0xff, 0xff, 0xff, 0xff),
                "16x16@32bpp unknown");
        assertFallsBackToDirectory(patch(file, bmpWidth, 0, 0, 0, 0x80), "16x16@32bpp unknown");
        assertFallsBackToDirectory(patch(file, bmpHeight, 0, 0, 0, 0), "16x16@32bpp unknown");
        assertFallsBackToDirectory(patch(file, bmpHeight, 0, 0, 0, 0x80), "16x16@32bpp unknown");
        // big endian Integer.MIN_VALUE, Integer.MAX_VALUE and one past the largest size believed
        assertFallsBackToDirectory(patch(file, pngWidth, 0x80, 0, 0, 0), "256x256@32bpp unknown");
        assertFallsBackToDirectory(patch(file, pngHeight, 0x80, 0, 0, 0), "256x256@32bpp unknown");
        assertFallsBackToDirectory(patch(file, pngHeight, 0, 0, 0, 0), "256x256@32bpp unknown");
        assertFallsBackToDirectory(patch(file, pngWidth, 0x7f, 0xff, 0xff, 0xff),
                "256x256@32bpp unknown");
        assertFallsBackToDirectory(patch(file, pngWidth, 0, 1, 0, 0), "256x256@32bpp unknown");
        // two negative dimensions have a positive product, which must not win
        assertFallsBackToDirectory(
                patch(patch(file, pngWidth, 0x80, 0, 0, 0), pngHeight, 0x80, 0, 0, 0),
                "256x256@32bpp unknown");
    }

    /**
     * The fixtures are all 32 bpp; the other depths are patched into their headers.
     */
    @Test
    public void testColourDepths() throws Exception {
        byte[] bmpOnly = readTestResource("testICO_bmpOnly.ico");
        // the 48 px image is the second entry; a BITMAPINFOHEADER holds the bit count at 14
        int bitCount = imageOffset(bmpOnly, 1) + 14;
        assertDepth(patch(bmpOnly, bitCount, 24, 0), "48x48@24bpp bmp", "8", 3);
        assertDepth(patch(bmpOnly, bitCount, 16, 0), "48x48@16bpp bmp", "5", 3);
        assertDepth(patch(bmpOnly, bitCount, 8, 0), "48x48@8bpp bmp", "8", 1);
        assertDepth(patch(bmpOnly, bitCount, 1, 0), "48x48@1bpp bmp", "1", 1);

        byte[] withPng = readTestResource("testICO.ico");
        // an IHDR holds the bit depth at 24 and the colour type at 25
        int bitDepth = imageOffset(withPng, 2) + 24;
        assertDepth(patch(withPng, bitDepth, 8, 2), "256x256@24bpp png", "8", 3);
        assertDepth(patch(withPng, bitDepth, 8, 4), "256x256@16bpp png", "8", 2);
        assertDepth(patch(withPng, bitDepth, 8, 3), "256x256@8bpp png", "8", 1);
        assertDepth(patch(withPng, bitDepth, 16, 6), "256x256@64bpp png", "16", 4);
    }

    /**
     * A depth no DIB or PNG can have is ignored; the directory's 32 bpp stands in.
     */
    @Test
    public void testImplausibleColourDepths() throws Exception {
        byte[] bmpOnly = readTestResource("testICO_bmpOnly.ico");
        int bitCount = imageOffset(bmpOnly, 1) + 14;
        assertDepth(patch(bmpOnly, bitCount, 0xff, 0xff), "48x48@32bpp bmp", "8", 4);
        assertDepth(patch(bmpOnly, bitCount, 7, 0), "48x48@32bpp bmp", "8", 4);

        byte[] withPng = readTestResource("testICO.ico");
        int bitDepth = imageOffset(withPng, 2) + 24;
        assertDepth(patch(withPng, bitDepth, 0xff, 6), "256x256@32bpp png", "8", 4);
        assertDepth(patch(withPng, bitDepth, 8, 5), "256x256@32bpp png", "8", 4);

        // the directory's own bit count is no more trusted: the 32 px image is cut and has no other
        byte[] cut = Arrays.copyOf(patch(withPng, 6 + 16 + 6, 0xff, 0xff), imageOffset(withPng, 1) + 10);
        assertContains("32x32 unknown", Arrays.asList(parse(cut).getValues(Icon.IMAGES)));
    }

    private static void assertDepth(byte[] file, String expectedImage, String bitsPerSample,
                                    int samplesPerPixel) throws Exception {
        Metadata metadata = parse(file);
        assertContains(expectedImage, Arrays.asList(metadata.getValues(Icon.IMAGES)));
        assertEquals(bitsPerSample, metadata.get(TIFF.BITS_PER_SAMPLE));
        assertEquals(samplesPerPixel, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
    }

    @Test
    public void testEmptyDirectory() throws Exception {
        Metadata metadata = parse(new byte[]{0, 0, 1, 0, 0, 0});
        assertEquals("image/vnd.microsoft.icon", metadata.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(0, metadata.getInt(Icon.IMAGE_COUNT));
        assertContains("no images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    @Test
    public void testHeaderCutBeforeImageCount() throws Exception {
        for (byte[] header : new byte[][]{{0, 0, 2, 0}, {0, 0, 2, 0, 1}}) {
            Metadata metadata = parse(header);
            assertEquals("image/x-win-bitmap", metadata.get(HttpHeaders.CONTENT_TYPE));
            assertNull(metadata.get(Icon.IMAGE_COUNT));
            assertContains("image count",
                    metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
        }
    }

    /**
     * An image whose header lies beyond the size limit is as unreadable as one
     * beyond the end of the file.
     */
    @Test
    public void testSizeLimit() throws Exception {
        byte[] file = readTestResource("testICO.ico");
        int limit = ICOParser.MAX_FILE_SIZE;
        byte[] large = Arrays.copyOf(file, limit + 64);
        // the 256 px image's PNG header, copied to just beyond the limit, and its entry's offset
        System.arraycopy(file, imageOffset(file, 2), large, limit, 29);
        int offset = 6 + 2 * 16 + 12;
        large[offset] = (byte) limit;
        large[offset + 1] = (byte) (limit >> 8);
        large[offset + 2] = (byte) (limit >> 16);
        large[offset + 3] = (byte) (limit >> 24);

        Metadata metadata = parse(large);
        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
                metadata.getValues(Icon.IMAGES));
        assertContains("1 of 3 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * @return where the image with the given index starts, as its directory entry says
     */
    private static int imageOffset(byte[] file, int index) {
        return (int) EndianUtils.getUIntLE(file, 6 + index * 16 + 12);
    }

    private static byte[] patch(byte[] file, int offset, int... bytes) {
        byte[] patched = file.clone();
        for (int i = 0; i < bytes.length; i++) {
            patched[offset + i] = (byte) bytes[i];
        }
        return patched;
    }

    private static void assertFallsBackToDirectory(byte[] file, String expected) throws Exception {
        Metadata metadata = parse(file);
        assertContains(expected, Arrays.asList(metadata.getValues(Icon.IMAGES)));
        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
        assertEquals(256, metadata.getInt(TIFF.IMAGE_LENGTH));
        assertContains("1 of 3 images", metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    @Test
    public void testNotAnIcon() {
        assertThrows(TikaException.class, () -> parse(new byte[]{0, 0, 3, 0, 1, 0}));
        assertThrows(TikaException.class, () -> parse(new byte[]{1, 2, 3}));
    }

    private Metadata parse(String name) throws Exception {
        return parse(readTestResource(name));
    }

    private static Metadata parse(byte[] file) throws Exception {
        Metadata metadata = new Metadata();
        try (TikaInputStream tis = TikaInputStream.get(file)) {
            new ICOParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
        }
        return metadata;
    }

    private byte[] readTestResource(String name) throws Exception {
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + name)) {
            return tis.readAllBytes();
        }
    }
}