PEIconExtractorTest.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.executable;

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

import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;

import org.apache.commons.io.input.CountingInputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.xml.sax.ContentHandler;

import org.apache.tika.TikaTest;
import org.apache.tika.config.EmbeddedLimits;
import org.apache.tika.config.ParseTimeout;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.exception.EmbeddedLimitReachedException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.io.EndianUtils;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.ParseRecord;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.BodyContentHandler;
import org.apache.tika.sax.XHTMLContentHandler;

/**
 * The test executables were built with MinGW from an empty {@code main()}
 * respectively an empty DLL plus a resource script that embeds two icon
 * groups: id 1 (from testWindows-icons-app.ico, 16/32 px BMP + 256 px PNG)
 * and the named group DOCICON (from testWindows-icons-doc.ico, 16/48 px BMP).
 * testWindows-x86-64-icons-lang.dll instead carries group 1 twice, in
 * language 1033 (app.ico) and 1031 (doc.ico). The two .ico files were drawn
 * for these tests, a blue circle and a red square.
 * <p>
 * To rebuild them, on Ubuntu 24.04 with gcc-mingw-w64 13.2.0 (13-win32) and
 * binutils-mingw-w64 2.41.90; the result differs from the files here only in
 * the link timestamp (0x88, in the DLLs also 0xc04) and the checksum (0xd8):
 * <pre>
 * main.c:         int main(void) { return 0; }
 * dll.c:          __declspec(dllexport) int tika_answer(void) { return 42; }
 * icons.rc:       1 ICON "testWindows-icons-app.ico"
 *                 DOCICON ICON "testWindows-icons-doc.ico"
 * icons-lang.rc:  LANGUAGE 9, 1
 *                 1 ICON "testWindows-icons-app.ico"
 *                 LANGUAGE 7, 1
 *                 1 ICON "testWindows-icons-doc.ico"
 *
 * i686-w64-mingw32-windres icons.rc icons32.o
 * i686-w64-mingw32-gcc -Os -s -o testWindows-x86-32-icons.exe main.c icons32.o
 * x86_64-w64-mingw32-windres icons.rc icons64.o
 * x86_64-w64-mingw32-gcc -Os -s -shared -nostdlib -o testWindows-x86-64-icons.dll \
 *         dll.c icons64.o
 * x86_64-w64-mingw32-windres icons-lang.rc icons-lang64.o
 * x86_64-w64-mingw32-gcc -Os -s -shared -nostdlib -o testWindows-x86-64-icons-lang.dll \
 *         dll.c icons-lang64.o
 * </pre>
 * The -rebuilt.ico files hold the same images as the .ico files the resources
 * were compiled from, ordered largest first, which is what the extractor writes.
 *
 * Layout of testWindows-x86-32-icons.exe (file offsets, from pefile):
 * .rsrc raw 0x3400-0x7c00 at RVA 0xa000; root directory 0x3400 with the
 * type entries at 0x3410 (RT_ICON) and 0x3418 (RT_GROUP_ICON); data entries
 * 0x3530-0x3590 (icons 1-5, DOCICON, group 1); icon data 0x35a0-0x7b18
 * (icon 2 spans 0x3a08-0x4ab0); GRPICONDIR DOCICON 0x7b18-0x7b3a and
 * group 1 0x7b40-0x7b70.
 */
public class PEIconExtractorTest extends TikaTest {

    private static final String EXE = "testWindows-x86-32-icons.exe";
    private static final String DLL = "testWindows-x86-64-icons.dll";
    private static final String LANG_DLL = "testWindows-x86-64-icons-lang.dll";
    private static final int ICON_DIR_ENTRY_SIZE = 16;
    private static final String APP_ICO_SOURCE = "testWindows-icons-app.ico";
    private static final String APP_ICO = "testWindows-icons-app-rebuilt.ico";
    private static final String DOC_ICO = "testWindows-icons-doc-rebuilt.ico";

    private static final String THUMBNAIL =
            TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString();
    private static final String ATTACHMENT =
            TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString();

    @Test
    public void testIconsFromExe() throws Exception {
        List<Metadata> metadataList = getRecursiveMetadata(EXE, allIcons());
        assertEquals(3, metadataList.size());

        Metadata exe = metadataList.get(0);
        assertEquals("application/x-msdownload", exe.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(ExecutableParser.MACHINE_x86_32, exe.get(ExecutableParser.MACHINE_TYPE));
        assertEquals("32", exe.get(ExecutableParser.ARCHITECTURE_BITS));
        assertNull(exe.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
        assertNull(exe.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));

        // Named entries precede numeric ids in the resource directory, so the
        // DOCICON group is the first one and therefore the file's own icon
        assertIcon(metadataList.get(1), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);
        assertIcon(metadataList.get(2), "icon_1.ico", "14/1/1033", ATTACHMENT);
    }

    /**
     * By default only the file's own icon comes out: a DLL can carry thousands of groups.
     */
    @Test
    public void testFirstGroupOnlyByDefault() throws Exception {
        List<Metadata> metadataList = getRecursiveMetadata(EXE);
        assertEquals(2, metadataList.size());
        assertIcon(metadataList.get(1), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);

        RecordingExtractor extractor = new RecordingExtractor();
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            new ExecutableParser().parse(tis, new BodyContentHandler(), new Metadata(),
                    extractor.context());
        }
        assertEquals(1, extractor.metadata.size(), "asked about the first group only");
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));

        // all groups for one request
        extractor = new RecordingExtractor();
        ParseContext context = extractor.context();
        context.setJsonConfig("executable-parser", "{\"extractAllIcons\": true}");
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            new ExecutableParser().parse(tis, new BodyContentHandler(), new Metadata(), context);
        }
        assertEquals(2, extractor.contents.size());
    }

    @Test
    public void testIconsFromDll() throws Exception {
        List<Metadata> metadataList = getRecursiveMetadata(DLL, allIcons());
        assertEquals(3, metadataList.size());

        Metadata dll = metadataList.get(0);
        assertEquals("application/x-msdownload", dll.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(ExecutableParser.MACHINE_x86_64, dll.get(ExecutableParser.MACHINE_TYPE));
        assertEquals("64", dll.get(ExecutableParser.ARCHITECTURE_BITS));

        assertIcon(metadataList.get(1), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);
        assertIcon(metadataList.get(2), "icon_1.ico", "14/1/1033", ATTACHMENT);
    }

    /**
     * The icon's name is Tika's invention, so it must not become text of the file.
     */
    @Test
    public void testIconsAddNoText() throws Exception {
        assertContains("<body />", getXML(EXE).xml);
    }

    /**
     * A reader that takes an icon group for a multi-page image hands out the
     * first entry, so the largest image goes there. The app icon is compiled
     * from a 16, a 32 and a 256 pixel image, in that order; a width of 0 in a
     * directory entry stands for 256.
     */
    @Test
    public void testLargestIconComesFirst() throws Exception {
        assertArrayEquals(new int[]{16, 32, 0}, widths(readTestResource(APP_ICO_SOURCE)),
                "the icon the resources were compiled from");

        RecordingExtractor extractor = parse(readTestResource(EXE));
        assertArrayEquals(new int[]{0, 32, 16}, widths(extractor.contents.get(1)));
    }

    /**
     * A side of 0 stands for anything from 256 up, so only the images tell a
     * 1024 pixel PNG from a 256 pixel bitmap, and an entry that claims 16
     * pixels for a 48 pixel bitmap is not believed either. The images differ
     * in length, which is how the result tells them apart.
     */
    @Test
    public void testOrderFollowsTheImagesNotTheDirectory() throws Exception {
        byte[] pe = groupPe(new int[][]{{0, 32}, {0, 32}, {16, 32}, {32, 32}},
                bitmap(41, 256, 256), png(42, 1024, 1024), bitmap(43, 48, 48),
                bitmap(44, 32, 32));
        RecordingExtractor extractor = parse(pe);
        assertEquals(1, extractor.contents.size());
        assertArrayEquals(new int[]{42, 41, 43, 44}, imageLengths(extractor.contents.get(0)));
    }

    /**
     * Among images of one size the deeper one comes first, and those that
     * agree in both keep the order of the group.
     */
    @Test
    public void testColourDepthBreaksTies() throws Exception {
        byte[] pe = groupPe(new int[][]{{32, 4}, {32, 32}, {32, 8}, {32, 32}},
                bitmap(41, 32, 32), bitmap(42, 32, 32), bitmap(43, 32, 32),
                bitmap(44, 32, 32));
        RecordingExtractor extractor = parse(pe);
        assertEquals(1, extractor.contents.size());
        assertArrayEquals(new int[]{42, 44, 43, 41}, imageLengths(extractor.contents.get(0)));
    }

    /**
     * A size no image can have - sides beyond what PNG allows, a negative
     * width, no height at all - is not sorted by, and neither is what follows
     * a PNG signature without an IHDR chunk; the directory entry is. The
     * entries rank the images the other way round than those sizes would.
     */
    @Test
    public void testImplausibleImageSizeFallsBackToTheDirectory() throws Exception {
        byte[] noIhdr = png(45, 1024, 1024);
        noIhdr[12] = 'J';
        byte[] pe = groupPe(new int[][]{{16, 32}, {8, 32}, {48, 32}, {32, 32}, {24, 32}},
                bitmap(44, 16, 16), noIhdr, png(41, -1, -1), bitmap(42, -4096, 4096),
                bitmap(43, 4096, 0));
        RecordingExtractor extractor = parse(pe);
        assertEquals(1, extractor.contents.size());
        assertArrayEquals(new int[]{41, 42, 43, 44, 45},
                imageLengths(extractor.contents.get(0)));
    }

    /** The width of every directory entry of an .ico, in file order. */
    private static int[] widths(byte[] ico) {
        int[] widths = new int[ico[4] & 0xff];
        for (int i = 0; i < widths.length; i++) {
            widths[i] = ico[6 + i * ICON_DIR_ENTRY_SIZE] & 0xff;
        }
        return widths;
    }

    /**
     * The resource compiler copies the images verbatim, so the rebuilt .ico
     * files hold the images of the ones that went in, ordered largest first.
     * Checks the metadata as handed to the extractor, before any re-detection.
     */
    @Test
    public void testReconstructedIcoIsByteIdentical() throws Exception {
        for (String file : new String[]{EXE, DLL}) {
            RecordingExtractor extractor = parse(readTestResource(file));
            assertEquals(2, extractor.contents.size(), file);
            assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0), file);
            assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(1), file);
            assertIcon(extractor.metadata.get(0), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);
            assertIcon(extractor.metadata.get(1), "icon_1.ico", "14/1/1033", ATTACHMENT);
        }
    }

    /**
     * File-backed input is read through a positioned channel rather than by
     * skipping; the result must not differ.
     */
    @Test
    public void testFileBackedInput() throws Exception {
        RecordingExtractor extractor = new RecordingExtractor();
        try (TikaInputStream tis = TikaInputStream.get(getResourceAsFile("/test-documents/" + EXE)
                .toPath())) {
            assertTrue(tis.hasFile());
            allIcons().parse(tis, new BodyContentHandler(), new Metadata(),
                    extractor.context());
        }
        assertEquals(2, extractor.contents.size());
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
        assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(1));
    }

    @Test
    public void testLanguageVariants() throws Exception {
        RecordingExtractor extractor = parse(readTestResource(LANG_DLL));
        assertEquals(2, extractor.contents.size());
        // 1031 sorts before 1033 in the language directory
        assertIcon(extractor.metadata.get(0), "icon_1_1031.ico", "14/1/1031", THUMBNAIL);
        assertIcon(extractor.metadata.get(1), "icon_1_1033.ico", "14/1/1033", ATTACHMENT);
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
        assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(1));
    }

    /**
     * A group whose language has no matching icons falls back to whatever
     * language the icons come in: icons 4 and 5 are relabelled from 1031 to
     * 1033 (their language entries at 0x10b0 and 0x10c8), the 1031 group
     * must still find them.
     */
    @Test
    public void testLanguageFallback() throws Exception {
        byte[] dll = readTestResource(LANG_DLL);
        putIntLE(dll, 0x10b0, 1033);
        putIntLE(dll, 0x10c8, 1033);
        RecordingExtractor extractor = parse(dll);
        assertEquals(2, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1_1031.ico", "14/1/1031", THUMBNAIL);
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
    }

    /**
     * The GRPICONDIR's BytesInRes is not trusted; the icon resource's own
     * size is. Corrupting BytesInRes of DOCICON's first entry (0x7b18 + 6 + 8)
     * must not change the output.
     */
    @Test
    public void testGroupSizeFieldIsIgnored() throws Exception {
        byte[] exe = readTestResource(EXE);
        putIntLE(exe, 0x7b26, 0x00ffffff);
        RecordingExtractor extractor = parse(exe);
        assertEquals(2, extractor.contents.size());
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
    }

    /**
     * A group that lists the same icon twice could inflate to 256 times the
     * icon; it is dropped and the next group takes the thumbnail slot.
     */
    @Test
    public void testDuplicateIconIdInGroupIsRejected() throws Exception {
        byte[] exe = readTestResource(EXE);
        // nID of DOCICON's second entry (0x7b18 + 6 + 14 + 12) := id of the first (4)
        exe[0x7b38] = 4;
        exe[0x7b39] = 0;
        RecordingExtractor extractor = parse(exe);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1.ico", "14/1/1033", THUMBNAIL);
        assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(0));
    }

    /**
     * Two ids can name one image: icon 5's data entry (0x3570) is pointed at
     * icon 4's data (0x3560), so DOCICON lists the same bytes twice under
     * different ids. It is dropped like a repeated id.
     */
    @Test
    public void testSharedImageDataInGroupIsRejected() throws Exception {
        byte[] exe = readTestResource(EXE);
        System.arraycopy(exe, 0x3560, exe, 0x3570, 8);
        RecordingExtractor extractor = parse(exe);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1.ico", "14/1/1033", THUMBNAIL);
        assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(0));
    }

    /**
     * Images may not share bytes at all: icon 5's data entry (0x3570) starts
     * one byte into icon 4's data.
     */
    @Test
    public void testOverlappingImagesInGroupAreRejected() throws Exception {
        byte[] exe = readTestResource(EXE);
        putIntLE(exe, 0x3570, EndianUtils.getIntLE(exe, 0x3560) + 1);
        putIntLE(exe, 0x3574, EndianUtils.getIntLE(exe, 0x3564));
        RecordingExtractor extractor = parse(exe);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1.ico", "14/1/1033", THUMBNAIL);
    }

    /**
     * What goes wrong while an icon is handed on is not a broken resource
     * section: it reaches the caller like any other embedded document's failure.
     */
    @Test
    public void testEmbeddedFailureSurfaces() throws Exception {
        RecordingExtractor extractor = new RecordingExtractor() {
            @Override
            public void parseEmbedded(TikaInputStream stream, ContentHandler handler,
                                      Metadata metadata, ParseContext context,
                                      boolean outputHtml) throws IOException {
                throw new EOFException("embedded failed");
            }
        };
        assertThrows(EOFException.class, () -> parse(readTestResource(EXE), extractor));
        assertNull(extractor.parentMetadata.get(
                TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    /**
     * Any number of groups can share one image, each rebuilding it into a file
     * of its own. Together they may not outgrow a small multiple of the
     * section they were read from; extraction stops there with a warning.
     */
    @Test
    public void testOutputBudgetAcrossGroups() throws Exception {
        int groups = 40;
        int imageSize = 4096;
        byte[] pe = sharedImagePe(groups, imageSize);
        RecordingExtractor extractor = parse(pe);
        long emitted = 0;
        for (byte[] ico : extractor.contents) {
            emitted += ico.length;
        }
        assertTrue(emitted > imageSize, "emitted " + emitted);
        assertTrue(emitted <= 4L * (pe.length - SyntheticPE.SECTION_RAW_PTR), "emitted " + emitted);
        assertTrue(extractor.contents.size() < groups);
        assertContains("stopped early",
                extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * The entry budget is spent on the groups first: 12000 icons take more
     * than the 20000 entries allowed, yet the group whose icon was reached
     * before the budget ran out still comes out.
     */
    @Test
    public void testGroupsAreReadBeforeIcons() throws Exception {
        RecordingExtractor extractor = parse(manyIconsPe());
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1.ico", "14/1/1033", THUMBNAIL);
        assertContains("20000 entries",
                extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * A group may list 256 images, not 257.
     */
    @Test
    public void testIconsPerGroupLimit() throws Exception {
        int[] ids = new int[257];
        for (int i = 0; i < ids.length; i++) {
            ids[i] = i + 1;
        }
        byte[] tooMany = grpIconDir(ids);
        byte[] allowed = grpIconDir(Arrays.copyOf(ids, 256));
        byte[] data = new byte[tooMany.length + allowed.length + ids.length];
        System.arraycopy(tooMany, 0, data, 0, tooMany.length);
        System.arraycopy(allowed, 0, data, tooMany.length, allowed.length);
        SyntheticResources resources = new SyntheticResources(data);
        resources.group(0, tooMany.length);
        resources.group(tooMany.length, allowed.length);
        for (int i = 0; i < ids.length; i++) {
            resources.icon(tooMany.length + allowed.length + i, 1);
        }
        RecordingExtractor extractor = parse(SyntheticPE.build(resources.build(), 0));
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_2.ico", "14/2/1033", THUMBNAIL);
        assertEquals(6 + 256 * 16 + 256, extractor.contents.get(0).length);
    }

    /**
     * A group whose images add up to more than 64 MB is skipped on its own;
     * the group after it still comes out and nothing is reported. The three
     * 23 MB images lie in the hole of a sparse file, which only a file-backed
     * parse can reach.
     */
    @Test
    public void testOversizedGroupIsSkipped(@TempDir Path tmp) throws Exception {
        int imageSize = 23 * 1024 * 1024;
        byte[] oversized = grpIconDir(1, 2, 3);
        byte[] small = grpIconDir(4);
        int images = oversized.length + small.length;
        byte[] data = new byte[images + 16];
        System.arraycopy(oversized, 0, data, 0, oversized.length);
        System.arraycopy(small, 0, data, oversized.length, small.length);
        SyntheticResources resources = new SyntheticResources(data);
        resources.group(0, oversized.length);
        resources.group(oversized.length, small.length);
        for (int i = 0; i < 3; i++) {
            resources.icon(data.length + i * imageSize, imageSize);
        }
        resources.icon(images, 16);
        byte[] section = resources.build();
        int declared = section.length + 3 * imageSize;
        Path file = Files.write(tmp.resolve("oversized.exe"),
                SyntheticPE.build(section, 0, declared));
        try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
            channel.write(ByteBuffer.allocate(1), SyntheticPE.SECTION_RAW_PTR + declared - 1);
        }
        RecordingExtractor extractor = parseFile(file);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_2.ico", "14/2/1033", THUMBNAIL);
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * A resource name ends up in a file name and in an id that is separated
     * by slashes: DOCICON's I (0x3528) becomes a slash.
     */
    @Test
    public void testResourceNameIsCleaned() throws Exception {
        byte[] exe = readTestResource(EXE);
        exe[0x3528] = '/';
        RecordingExtractor extractor = parse(exe);
        assertEquals(2, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_DOC_CON.ico", "14/DOC_CON/1033", THUMBNAIL);
    }

    /**
     * A name can spell an id: DOCICON is renamed to "1" (length at 0x3520,
     * characters from 0x3522) next to the group with id 1. Only one of the
     * two can be told apart by name and id, so only one comes out.
     */
    @Test
    public void testNameThatSpellsAnId() throws Exception {
        byte[] exe = readTestResource(EXE);
        exe[0x3520] = 1;
        exe[0x3522] = '1';
        RecordingExtractor extractor = parse(exe);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_1.ico", "14/1/1033", THUMBNAIL);
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
    }

    /**
     * A stream that starts after the four bytes the caller has already read
     * is all the public entry point promises; the section must still be found.
     */
    @Test
    public void testStreamStartingAfterFirstFourBytes() throws Exception {
        byte[] exe = readTestResource(EXE);
        RecordingExtractor extractor = new RecordingExtractor();
        ParseContext context = extractor.context();
        try (TikaInputStream tis = TikaInputStream.get(Arrays.copyOfRange(exe, 4, exe.length))) {
            XHTMLContentHandler xhtml = new XHTMLContentHandler(new BodyContentHandler(),
                    extractor.parentMetadata, context);
            allIcons().parsePE(xhtml, extractor.parentMetadata, tis,
                    Arrays.copyOf(exe, 4), context);
        }
        assertEquals(2, extractor.contents.size());
        assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
    }

    /**
     * A section larger than what is buffered from a stream still yields the
     * icons within reach; one beyond it is reported. A file has no such limit.
     */
    @Test
    public void testSectionLargerThanBuffer(@TempDir Path tmp) throws Exception {
        int declared = 100 * 1024 * 1024;
        byte[] rsrc = resourceSectionAt(SyntheticPE.SECTION_VA);
        byte[] pe = SyntheticPE.build(rsrc, 0, declared);
        RecordingExtractor extractor = parse(pe);
        assertEquals(2, extractor.contents.size());
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));

        extractor = parseFile(Files.write(tmp.resolve("large-section.exe"), pe));
        assertEquals(2, extractor.contents.size());

        // icon 1 (data entry 0x130) now lies 70 MB into the section
        putIntLE(rsrc, 0x130, SyntheticPE.SECTION_VA + 70 * 1024 * 1024);
        extractor = parse(SyntheticPE.build(rsrc, 0, declared));
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);
        assertContains("64 MB",
                extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * A file that ends before its resource section is reported the same way
     * whether it is read from a file or from a stream.
     */
    @Test
    public void testFileBackedTruncation(@TempDir Path tmp) throws Exception {
        RecordingExtractor extractor = parseFile(Files.write(tmp.resolve("truncated.exe"),
                Arrays.copyOf(readTestResource(EXE), 0x3000)));
        assertEquals(0, extractor.contents.size());
        assertEquals(ExecutableParser.MACHINE_x86_32,
                extractor.parentMetadata.get(ExecutableParser.MACHINE_TYPE));
        assertContains("EOFException", extractor.parentMetadata.get(
                TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    /**
     * A file and a stream take different paths through the resource section.
     * Whatever the input, they must come to the same icons and the same notes.
     */
    @Test
    public void testFileBackedInputBehavesLikeStream(@TempDir Path tmp) throws Exception {
        byte[] exe = readTestResource(EXE);
        Map<String, byte[]> inputs = new LinkedHashMap<>();
        inputs.put("exe", exe);
        inputs.put("dll", readTestResource(DLL));
        inputs.put("language variants", readTestResource(LANG_DLL));
        inputs.put("cycle", patched(exe, 0x3414, 0x80000000));
        inputs.put("data outside the section", patched(exe, 0x3530, 0x1000));
        inputs.put("images sharing bytes",
                patched(exe, 0x3570, EndianUtils.getIntLE(exe, 0x3560) + 1));
        inputs.put("name with a slash", patched(exe, 0x3528, '/'));
        for (int length : new int[]{0x200, 0x3000, 0x3400, 0x4000, 0x7b20, 0x7b50, 0x7fff}) {
            inputs.put("cut at 0x" + Integer.toHexString(length), Arrays.copyOf(exe, length));
        }
        inputs.put("output budget", sharedImagePe(40, 4096));
        inputs.put("entry budget", manyIconsPe());

        for (Map.Entry<String, byte[]> input : inputs.entrySet()) {
            String label = input.getKey();
            RecordingExtractor stream = parse(input.getValue());
            RecordingExtractor file = parseFile(
                    Files.write(Files.createTempFile(tmp, "pe", ".exe"), input.getValue()));
            assertEquals(stream.contents.size(), file.contents.size(), label);
            for (int i = 0; i < stream.contents.size(); i++) {
                assertArrayEquals(stream.contents.get(i), file.contents.get(i), label);
            }
            assertEquals(stream.metadata.size(), file.metadata.size(), label);
            for (int i = 0; i < stream.metadata.size(); i++) {
                assertEquals(stream.metadata.get(i), file.metadata.get(i), label);
            }
            assertEquals(warning(stream), warning(file), label);
            // the messages differ, what was noticed must not
            assertEquals(stream.parentMetadata.get(
                            TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM) == null,
                    file.parentMetadata.get(
                            TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM) == null,
                    label);
        }
    }

    /**
     * The section buffer follows what was read, not what the header declares:
     * 64 MB declared, 100 bytes there.
     */
    @Test
    public void testSectionBufferFollowsBytesRead() throws Exception {
        int declared = 64 * 1024 * 1024;
        PEIconExtractor.BufferedSection section = new PEIconExtractor.BufferedSection(
                new ByteArrayInputStream(new byte[100]), declared);
        assertNull(section.read(declared - 16, 16));
        assertTrue(section.buf.length <= 64 * 1024, "allocated " + section.buf.length);
        assertEquals(90, section.read(10, 90).length);
    }

    /**
     * A failing source is not a broken resource section: it fails the parse
     * instead of being noted as an embedded problem.
     */
    @Test
    public void testSourceFailureSurfaces() throws Exception {
        InputStream failing = new FilterInputStream(
                new ByteArrayInputStream(readTestResource(EXE))) {
            // gives out before the resource section at 0x3400
            private long remaining = 0x3000;

            @Override
            public int read() throws IOException {
                byte[] one = new byte[1];
                return read(one, 0, 1) < 0 ? -1 : one[0] & 0xff;
            }

            @Override
            public int read(byte[] b, int off, int len) throws IOException {
                if (remaining <= 0) {
                    throw new IOException("source failed");
                }
                int n = super.read(b, off, (int) Math.min(len, remaining));
                remaining -= Math.max(n, 0);
                return n;
            }

            @Override
            public long skip(long n) throws IOException {
                if (remaining <= 0) {
                    throw new IOException("source failed");
                }
                long skipped = super.skip(Math.min(n, remaining));
                remaining -= skipped;
                return skipped;
            }
        };
        RecordingExtractor extractor = new RecordingExtractor();
        try (TikaInputStream tis = TikaInputStream.get(failing)) {
            assertThrows(IOException.class, () -> allIcons().parse(tis,
                    new BodyContentHandler(), extractor.parentMetadata, extractor.context()));
        }
        assertNull(extractor.parentMetadata.get(
                TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    /**
     * Icon 1's data entry (0x3530) is pointed outside the resource section;
     * group 1 loses an image and is dropped, DOCICON is unaffected.
     */
    @Test
    public void testDataOutsideSectionIsSkipped() throws Exception {
        byte[] exe = readTestResource(EXE);
        putIntLE(exe, 0x3530, 0x1000);
        RecordingExtractor extractor = parse(exe);
        assertEquals(1, extractor.contents.size());
        assertIcon(extractor.metadata.get(0), "icon_DOCICON.ico", "14/DOCICON/1033", THUMBNAIL);
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    /**
     * The RT_ICON type entry (0x3414) is pointed back at the root directory.
     */
    @Test
    public void testCycleInResourceTree() throws Exception {
        byte[] exe = readTestResource(EXE);
        putIntLE(exe, 0x3414, 0x80000000);
        RecordingExtractor extractor = parse(exe);
        assertEquals(0, extractor.contents.size());
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
        assertEquals(ExecutableParser.MACHINE_x86_32,
                extractor.parentMetadata.get(ExecutableParser.MACHINE_TYPE));
    }

    /**
     * Resource tree offsets are relative to the tree's root, which does not
     * have to be the start of the section. The real .rsrc is moved 0x300
     * bytes into a synthetic section.
     */
    @Test
    public void testResourceRootNotAtSectionStart() throws Exception {
        for (int shift : new int[]{0, 0x300}) {
            byte[] rsrc = resourceSectionAt(SyntheticPE.SECTION_VA + shift);
            byte[] section = new byte[shift + rsrc.length];
            System.arraycopy(rsrc, 0, section, shift, rsrc.length);
            RecordingExtractor extractor = parse(SyntheticPE.build(section, shift));
            assertEquals(2, extractor.contents.size(), "shift " + shift);
            assertArrayEquals(readTestResource(DOC_ICO), extractor.contents.get(0));
            assertArrayEquals(readTestResource(APP_ICO), extractor.contents.get(1));
        }
    }

    /**
     * A directory with more entries than the budget allows stops the walk
     * with a warning instead of an exception.
     */
    @Test
    public void testDirectoryEntryBudget() throws Exception {
        int entries = 30000;
        ByteBuffer rsrc = ByteBuffer.allocate(16 + 8 + 16 + 8 * entries)
                .order(ByteOrder.LITTLE_ENDIAN);
        // root: one id entry, type RT_ICON, pointing at the subdirectory at 24
        rsrc.position(12).putShort((short) 0).putShort((short) 1);
        rsrc.putInt(3).putInt(0x80000000 | 24);
        // subdirectory with too many entries, none of them leading anywhere
        rsrc.position(24 + 12).putShort((short) 0).putShort((short) entries);
        for (int i = 0; i < entries; i++) {
            rsrc.putInt(i).putInt(0);
        }
        RecordingExtractor extractor = parse(SyntheticPE.build(rsrc.array(), 0));
        assertEquals(0, extractor.contents.size());
        assertContains("20000 entries",
                extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
    }

    /**
     * Subdirectories below the language level are malformed and must stop the
     * walk there: a chain of 25000 nested directories under one language entry
     * would otherwise be followed to the end and exhaust the entry budget.
     */
    @Test
    public void testDepthCapBelowLanguageLevel() throws Exception {
        int chain = 25000;
        int dirSize = 16 + 8;
        ByteBuffer rsrc = ByteBuffer.allocate(dirSize * (3 + chain)).order(ByteOrder.LITTLE_ENDIAN);
        // root -> type RT_ICON -> name 1 -> language dir, then the chain
        for (int i = 0; i < 3 + chain; i++) {
            int dir = i * dirSize;
            rsrc.position(dir + 12).putShort((short) 0).putShort((short) 1);
            rsrc.putInt(i == 0 ? 3 : 1).putInt(0x80000000 | (dir + dirSize));
        }
        RecordingExtractor extractor = parse(SyntheticPE.build(rsrc.array(), 0));
        assertEquals(0, extractor.contents.size());
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
        assertNull(extractor.parentMetadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    /**
     * A file without icon types must not have its resource section read; the
     * synthetic section declares 1 MB behind a directory that only lists bitmaps.
     */
    @Test
    public void testSectionIsReadLazily() throws Exception {
        ByteBuffer rsrc = ByteBuffer.allocate(1024 * 1024).order(ByteOrder.LITTLE_ENDIAN);
        rsrc.position(12).putShort((short) 0).putShort((short) 1);
        rsrc.putInt(2).putInt(0x80000000 | 24); // RT_BITMAP
        byte[] pe = SyntheticPE.build(rsrc.array(), 0);
        CountingInputStream counting = new CountingInputStream(new ByteArrayInputStream(pe));
        RecordingExtractor extractor = new RecordingExtractor();
        try (TikaInputStream tis = TikaInputStream.get(counting)) {
            allIcons().parse(tis, new BodyContentHandler(), new Metadata(),
                    extractor.context());
        }
        assertEquals(0, extractor.contents.size());
        // TikaInputStream buffers in 8 KB blocks; the point is that the megabyte stays unread
        assertTrue(counting.getByteCount() < SyntheticPE.SECTION_RAW_PTR + 64 * 1024,
                "read " + counting.getByteCount() + " bytes");
    }

    @Test
    public void testExtractorMayDecline() throws Exception {
        RecordingExtractor extractor = new RecordingExtractor();
        extractor.accept = false;
        parse(readTestResource(EXE), extractor);
        assertEquals(2, extractor.metadata.size(), "asked about both groups");
        assertEquals(0, extractor.contents.size());
    }

    /**
     * Limits configured by the caller must surface, not be swallowed as a
     * broken resource section.
     */
    @Test
    public void testEmbeddedLimitSurfaces() throws Exception {
        ParseContext context = new ParseContext();
        context.set(EmbeddedLimits.class, new EmbeddedLimits(-1, false, 1, true));
        context.set(ParseRecord.class, ParseRecord.newInstance(context));
        context.set(ParseTimeout.class, ParseTimeout.start(new TimeoutLimits(60_000, 60_000)));
        context.setJsonConfig("executable-parser", "{\"extractAllIcons\": true}");
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            assertThrows(EmbeddedLimitReachedException.class, () -> AUTO_DETECT_PARSER
                    .parse(tis, new BodyContentHandler(), new Metadata(), context));
        }
    }

    @Test
    public void testExeWithoutIcons() throws Exception {
        List<Metadata> metadataList = getRecursiveMetadata("testWindows-x86-32.exe");
        assertEquals(1, metadataList.size());
        assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
    }

    @Test
    public void testExtractIconsDisabled() throws Exception {
        ExecutableParser.ExecutableParserConfig noIcons =
                new ExecutableParser.ExecutableParserConfig();
        noIcons.setExtractIcons(false);

        List<Metadata> metadataList = getRecursiveMetadata(EXE, new ExecutableParser(noIcons));
        assertEquals(1, metadataList.size());
        assertEquals(ExecutableParser.MACHINE_x86_32,
                metadataList.get(0).get(ExecutableParser.MACHINE_TYPE));

        // for one parse, through the context
        RecordingExtractor extractor = new RecordingExtractor();
        ParseContext context = extractor.context();
        context.set(ExecutableParser.ExecutableParserConfig.class, noIcons);
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            new ExecutableParser().parse(tis, new BodyContentHandler(), new Metadata(), context);
        }
        assertEquals(0, extractor.metadata.size());

        // from a JSON configuration
        Parser loaded = TikaLoader.load(getConfigPath(PEIconExtractorTest.class,
                "tika-config-executable-no-icons.json")).loadParsers();
        assertEquals(1, getRecursiveMetadata(EXE, loaded).size());
        assertEquals(2, getRecursiveMetadata(EXE).size());
    }

    /**
     * A request to tika-server or through pipes carries its configuration as
     * JSON under the parser's name, not as an object under its class.
     */
    @Test
    public void testExtractIconsPerRequestJson() throws Exception {
        RecordingExtractor extractor = new RecordingExtractor();
        ParseContext context = extractor.context();
        context.setJsonConfig("executable-parser", "{\"extractIcons\": false}");
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            new ExecutableParser().parse(tis, new BodyContentHandler(), new Metadata(), context);
        }
        assertEquals(0, extractor.metadata.size());

        // and the other way round, over a parser that was configured without icons
        ExecutableParser.ExecutableParserConfig noIcons =
                new ExecutableParser.ExecutableParserConfig();
        noIcons.setExtractIcons(false);
        extractor = new RecordingExtractor();
        context = extractor.context();
        context.setJsonConfig("executable-parser", "{\"extractIcons\": true}");
        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + EXE)) {
            new ExecutableParser(noIcons).parse(tis, new BodyContentHandler(), new Metadata(),
                    context);
        }
        assertEquals(1, extractor.contents.size());
    }

    /**
     * The pre-4.2.0 entry point still yields the metadata, just no icons.
     */
    @Test
    @SuppressWarnings("deprecation")
    public void testLegacyParsePE() throws Exception {
        Metadata metadata = new Metadata();
        byte[] exe = readTestResource(EXE);
        try (ByteArrayInputStream is = new ByteArrayInputStream(exe, 4, exe.length - 4)) {
            new ExecutableParser().parsePE(null, metadata, is, Arrays.copyOf(exe, 4));
        }
        assertEquals(ExecutableParser.MACHINE_x86_32, metadata.get(ExecutableParser.MACHINE_TYPE));
        assertEquals("Windows", metadata.get(ExecutableParser.PLATFORM));
    }

    /**
     * Cutting the file inside the resource section yields the icons that are
     * still complete, silently; cutting before it is reported, but never
     * costs the header metadata.
     */
    @Test
    public void testTruncatedFile() throws Exception {
        byte[] full = readTestResource(EXE);
        byte[] docIco = readTestResource(DOC_ICO);
        // length -> expected number of icons
        int[][] cases = {
                {0x200, 0},   // inside the section table
                {0x3400, 0},  // resource section entirely missing
                {0x4000, 0},  // inside icon 2, no group is complete
                {0x7b20, 0},  // inside DOCICON's GRPICONDIR
                {0x7b50, 1},  // inside group 1's GRPICONDIR, DOCICON is complete
                {0x7fff, 2},  // inside .reloc, after the resource section
        };
        for (int[] c : cases) {
            int length = c[0];
            RecordingExtractor extractor = parse(Arrays.copyOf(full, length));
            String msg = "length 0x" + Integer.toHexString(length);
            assertEquals(c[1], extractor.contents.size(), msg);
            assertEquals(ExecutableParser.MACHINE_x86_32,
                    extractor.parentMetadata.get(ExecutableParser.MACHINE_TYPE), msg);
            String recorded = extractor.parentMetadata.get(
                    TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM);
            if (length < 0x3400) {
                assertNotNull(recorded, msg);
                assertContains("EOFException", recorded);
            } else {
                assertNull(recorded, msg);
            }
            if (c[1] >= 1) {
                assertArrayEquals(docIco, extractor.contents.get(0), msg);
                assertIcon(extractor.metadata.get(0), "icon_DOCICON.ico", "14/DOCICON/1033",
                        THUMBNAIL);
            }
        }
    }

    private static void assertIcon(Metadata metadata, String name, String relationshipId,
                                   String resourceType) {
        assertEquals(name, metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY));
        assertEquals("true", metadata.get(TikaCoreProperties.RESOURCE_NAME_EXTENSION_INFERRED));
        assertEquals(PEIconExtractor.ICON_MIME_TYPE, metadata.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(relationshipId, metadata.get(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID));
        assertEquals(resourceType, metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
    }

    /**
     * @return a GRPICONDIR that lists the icons with the given ids
     */
    private static byte[] grpIconDir(int... ids) {
        ByteBuffer dir = ByteBuffer.allocate(6 + 14 * ids.length).order(ByteOrder.LITTLE_ENDIAN);
        dir.putShort((short) 0).putShort((short) 1).putShort((short) ids.length);
        for (int i = 0; i < ids.length; i++) {
            dir.putShort(6 + 14 * i + 12, (short) ids[i]);
        }
        return dir.array();
    }

    /**
     * @param entries the side and the bit count each directory entry claims
     * @return a file with one group of the given images, in the given order
     */
    private static byte[] groupPe(int[][] entries, byte[]... images) {
        byte[] dir = grpIconDir(IntStream.rangeClosed(1, images.length).toArray());
        int size = dir.length;
        for (byte[] image : images) {
            size += image.length;
        }
        ByteBuffer data = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN);
        SyntheticResources resources = new SyntheticResources(data.array());
        resources.group(0, dir.length);
        data.put(dir);
        for (int i = 0; i < images.length; i++) {
            data.put(6 + 14 * i, (byte) entries[i][0]).put(6 + 14 * i + 1, (byte) entries[i][0]);
            data.putShort(6 + 14 * i + 6, (short) entries[i][1]);
            resources.icon(data.position(), images[i].length);
            data.put(images[i]);
        }
        return SyntheticPE.build(resources.build(), 0);
    }

    /**
     * @return the start of a PNG of the given size, padded to the given length
     */
    private static byte[] png(int length, int width, int height) {
        ByteBuffer png = ByteBuffer.allocate(length);
        png.putLong(0x89504e470d0a1a0aL).putInt(13).putInt(0x49484452);
        png.putInt(width).putInt(height);
        return png.array();
    }

    /**
     * @return the BITMAPINFOHEADER of an icon image of the given size, its
     * height doubled for the mask, padded to the given length
     */
    private static byte[] bitmap(int length, int width, int height) {
        ByteBuffer bitmap = ByteBuffer.allocate(length).order(ByteOrder.LITTLE_ENDIAN);
        bitmap.putInt(40).putInt(width).putInt(2 * height);
        return bitmap.array();
    }

    /** The length of every image of an .ico, in file order. */
    private static int[] imageLengths(byte[] ico) {
        int[] lengths = new int[ico[4] & 0xff];
        for (int i = 0; i < lengths.length; i++) {
            lengths[i] = EndianUtils.getIntLE(ico, 6 + i * ICON_DIR_ENTRY_SIZE + 8);
        }
        return lengths;
    }

    /**
     * @return a file in which the given number of groups all list one image
     */
    private static byte[] sharedImagePe(int groups, int imageSize) {
        byte[] data = new byte[20 + imageSize];
        System.arraycopy(grpIconDir(1), 0, data, 0, 20);
        SyntheticResources resources = new SyntheticResources(data);
        for (int i = 0; i < groups; i++) {
            resources.group(0, 20);
        }
        resources.icon(20, imageSize);
        return SyntheticPE.build(resources.build(), 0);
    }

    /**
     * @return a file with one group and 12000 icons, more than the entry
     * budget of 20000 lets the walk visit
     */
    private static byte[] manyIconsPe() {
        byte[] data = new byte[20 + 16];
        System.arraycopy(grpIconDir(1), 0, data, 0, 20);
        SyntheticResources resources = new SyntheticResources(data);
        resources.group(0, 20);
        for (int i = 0; i < 12000; i++) {
            resources.icon(20, 16);
        }
        return SyntheticPE.build(resources.build(), 0);
    }

    /**
     * @return the resource section of the test EXE, its seven data entries
     * rebased from RVA 0xa000 to the given one
     */
    private byte[] resourceSectionAt(int rva) throws IOException {
        byte[] rsrc = Arrays.copyOfRange(readTestResource(EXE), 0x3400, 0x7c00);
        for (int entry = 0x130; entry <= 0x190; entry += 16) {
            putIntLE(rsrc, entry, EndianUtils.getIntLE(rsrc, entry) - 0xa000 + rva);
        }
        return rsrc;
    }

    /**
     * @return the warning's message without the stack trace that follows it
     */
    private static String warning(RecordingExtractor extractor) {
        String warning = extractor.parentMetadata.get(
                TikaCoreProperties.TIKA_META_EXCEPTION_WARNING);
        return warning == null ? null : warning.lines().findFirst().orElse("");
    }

    private static byte[] patched(byte[] data, int offset, int value) {
        byte[] patched = data.clone();
        putIntLE(patched, offset, value);
        return patched;
    }

    private static void putIntLE(byte[] data, int offset, int value) {
        ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(value);
    }

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

    private static ExecutableParser allIcons() {
        ExecutableParser.ExecutableParserConfig config =
                new ExecutableParser.ExecutableParserConfig();
        config.setExtractAllIcons(true);
        return new ExecutableParser(config);
    }

    private static RecordingExtractor parse(byte[] pe) throws Exception {
        return parse(pe, new RecordingExtractor());
    }

    private static RecordingExtractor parse(byte[] pe, RecordingExtractor extractor)
            throws Exception {
        try (TikaInputStream tis = TikaInputStream.get(pe)) {
            allIcons().parse(tis, new BodyContentHandler(), extractor.parentMetadata,
                    extractor.context());
        }
        return extractor;
    }

    private static RecordingExtractor parseFile(Path file) throws Exception {
        RecordingExtractor extractor = new RecordingExtractor();
        try (TikaInputStream tis = TikaInputStream.get(file)) {
            allIcons().parse(tis, new BodyContentHandler(), extractor.parentMetadata,
                    extractor.context());
        }
        return extractor;
    }

    /**
     * Records what the parser hands over, before any embedded parsing.
     */
    private static class RecordingExtractor implements EmbeddedDocumentExtractor {
        final List<byte[]> contents = new ArrayList<>();
        final List<Metadata> metadata = new ArrayList<>();
        final Metadata parentMetadata = new Metadata();
        boolean accept = true;

        ParseContext context() {
            ParseContext context = new ParseContext();
            context.set(EmbeddedDocumentExtractor.class, this);
            return context;
        }

        @Override
        public boolean shouldParseEmbedded(Metadata metadata, ParseContext context) {
            this.metadata.add(metadata);
            return accept;
        }

        @Override
        public void parseEmbedded(TikaInputStream stream, ContentHandler handler,
                                  Metadata metadata, ParseContext context, boolean outputHtml)
                throws IOException {
            contents.add(stream.readAllBytes());
        }
    }

    /**
     * A resource section whose icons and icon groups are numbered from 1 in
     * the order they are added, all in language 1033, their data being ranges
     * of one block that follows the tree.
     */
    private static final class SyntheticResources {
        private static final int DIR_SIZE = 16 + 8;
        private final List<int[]> icons = new ArrayList<>();
        private final List<int[]> groups = new ArrayList<>();
        private final byte[] data;

        SyntheticResources(byte[] data) {
            this.data = data;
        }

        void icon(int offset, int size) {
            icons.add(new int[]{offset, size});
        }

        void group(int offset, int size) {
            groups.add(new int[]{offset, size});
        }

        byte[] build() {
            int iconTypeDir = 16 + 2 * 8;
            int iconLangDirs = iconTypeDir + 16 + 8 * icons.size();
            int groupTypeDir = iconLangDirs + DIR_SIZE * icons.size();
            int groupLangDirs = groupTypeDir + 16 + 8 * groups.size();
            int dataEntries = groupLangDirs + DIR_SIZE * groups.size();
            int block = dataEntries + 16 * (icons.size() + groups.size());
            ByteBuffer rsrc = ByteBuffer.allocate(block + data.length)
                    .order(ByteOrder.LITTLE_ENDIAN);
            rsrc.position(12).putShort((short) 0).putShort((short) 2);
            rsrc.putInt(3).putInt(0x80000000 | iconTypeDir);
            rsrc.putInt(14).putInt(0x80000000 | groupTypeDir);
            writeType(rsrc, iconTypeDir, iconLangDirs, dataEntries, icons, block);
            writeType(rsrc, groupTypeDir, groupLangDirs, dataEntries + 16 * icons.size(), groups,
                    block);
            rsrc.position(block);
            rsrc.put(data);
            return rsrc.array();
        }

        private static void writeType(ByteBuffer rsrc, int typeDir, int langDirs, int dataEntries,
                                      List<int[]> resources, int block) {
            rsrc.position(typeDir + 12).putShort((short) 0).putShort((short) resources.size());
            for (int i = 0; i < resources.size(); i++) {
                rsrc.putInt(i + 1).putInt(0x80000000 | (langDirs + i * DIR_SIZE));
            }
            for (int i = 0; i < resources.size(); i++) {
                rsrc.position(langDirs + i * DIR_SIZE + 12).putShort((short) 0).putShort((short) 1);
                rsrc.putInt(1033).putInt(dataEntries + i * 16);
                rsrc.position(dataEntries + i * 16);
                rsrc.putInt(SyntheticPE.SECTION_VA + block + resources.get(i)[0]);
                rsrc.putInt(resources.get(i)[1]);
            }
        }
    }

    /**
     * The smallest PE32 file that gets a resource section past ExecutableParser:
     * DOS stub, PE signature, COFF header, full optional header, one .rsrc
     * section header, and the section itself at {@link #SECTION_RAW_PTR}.
     */
    private static final class SyntheticPE {
        static final int SECTION_VA = 0x1000;
        static final int SECTION_RAW_PTR = 0x200;
        private static final int PE_OFFSET = 0x40;
        private static final int OPT_HEADER_SIZE = 224;

        static byte[] build(byte[] section, int rootOffset) {
            return build(section, rootOffset, section.length);
        }

        /**
         * @param declaredSize the section size the header claims, whatever the section holds
         */
        static byte[] build(byte[] section, int rootOffset, int declaredSize) {
            ByteBuffer pe = ByteBuffer.allocate(SECTION_RAW_PTR + section.length)
                    .order(ByteOrder.LITTLE_ENDIAN);
            pe.put((byte) 'M').put((byte) 'Z');
            pe.putInt(0x3c, PE_OFFSET);
            pe.position(PE_OFFSET);
            pe.put("PE\0\0".getBytes(StandardCharsets.US_ASCII));
            pe.putShort((short) 0x14c).putShort((short) 1); // machine, one section
            pe.putInt(0).putInt(0).putInt(0);               // timestamp, symbols
            pe.putShort((short) OPT_HEADER_SIZE).putShort((short) 0x102);
            int opt = pe.position();
            pe.putShort((short) 0x10b);                     // PE32
            pe.putInt(opt + 92, 16);                        // NumberOfRvaAndSizes
            pe.putInt(opt + 96 + 2 * 8, SECTION_VA + rootOffset);
            pe.putInt(opt + 96 + 2 * 8 + 4, declaredSize - rootOffset);
            int sec = opt + OPT_HEADER_SIZE;
            pe.position(sec);
            pe.put(".rsrc\0\0\0".getBytes(StandardCharsets.US_ASCII));
            pe.putInt(declaredSize).putInt(SECTION_VA);
            pe.putInt(declaredSize).putInt(SECTION_RAW_PTR);
            pe.position(SECTION_RAW_PTR);
            pe.put(section);
            return pe.array();
        }
    }
}