ThreadSafeUnzipperTest.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.plugins;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

public class ThreadSafeUnzipperTest {

    private static final String MARKER = ".tika-extraction-complete";

    /**
     * Regression: a stale destination directory left over from a previous
     * extraction (killed mid-stream, marker removed, etc.) used to wedge the
     * unzipper into a permanent DirectoryNotEmptyException loop. The fix
     * detects the no-marker case and rebuilds.
     */
    @Test
    public void testStaleDestinationIsRebuilt(@TempDir Path tmp) throws Exception {
        Path zip = writeTrivialZip(tmp.resolve("plugin.zip"));
        Path destination = tmp.resolve("plugin");

        // Simulate a half-extracted state: destination exists with some files
        // but no completion marker.
        Files.createDirectories(destination);
        Files.writeString(destination.resolve("leftover.txt"), "stale");
        assertFalse(Files.exists(destination.resolve(MARKER)),
                "precondition: no completion marker");

        ThreadSafeUnzipper.unzipPlugin(zip);

        // After unzip, the leftover file must be gone (destination rebuilt)
        // and the marker must be present (extraction completed).
        assertFalse(Files.exists(destination.resolve("leftover.txt")),
                "stale file should be removed when destination is rebuilt");
        assertTrue(Files.exists(destination.resolve(MARKER)),
                "completion marker should be present after rebuild");
        assertTrue(Files.exists(destination.resolve("inside.txt")),
                "actual zip contents should be extracted");
    }

    /**
     * Happy path: when the destination already has the completion marker,
     * extraction is a no-op (does not touch the marker or contents).
     */
    @Test
    public void testCompletedDestinationIsLeftAlone(@TempDir Path tmp) throws Exception {
        Path zip = writeTrivialZip(tmp.resolve("plugin.zip"));
        Path destination = tmp.resolve("plugin");

        // Pre-populate as if a previous extraction completed successfully.
        Files.createDirectories(destination);
        Files.writeString(destination.resolve(MARKER), "");
        Files.writeString(destination.resolve("already-here.txt"), "untouched");

        ThreadSafeUnzipper.unzipPlugin(zip);

        assertTrue(Files.exists(destination.resolve("already-here.txt")),
                "no-op extraction should not touch existing contents");
        assertTrue(Files.exists(destination.resolve(MARKER)),
                "marker should still be present");
        assertFalse(Files.exists(destination.resolve("inside.txt")),
                "no-op extraction should NOT extract zip contents over the existing dir");
    }

    private static Path writeTrivialZip(Path zipPath) throws Exception {
        try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
            zos.putNextEntry(new ZipEntry("inside.txt"));
            zos.write("hello".getBytes(StandardCharsets.UTF_8));
            zos.closeEntry();
        }
        return zipPath;
    }
}