/*
 * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import jdk.test.lib.Utils;
import jdk.test.lib.process.OutputAnalyzer;
import jdk.test.lib.process.ProcessTools;
import jdk.test.lib.util.FileUtils;
import sun.net.www.ParseUtil;

import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.CharBuffer;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.Security;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

/*
 * @test
 * @summary Tests security properties passed through java.security,
 *   java.security.properties or included from other properties files.
 * @bug 8155246 8292297 8292177 8281658 8319332
 * @modules java.base/sun.net.www
 * @library /test/lib
 * @run main ConfigFileTest
 */

public class ConfigFileTest {
    static final String SEPARATOR_THIN = "----------------------------";

    private static void printTestHeader(String testName) {
        System.out.println();
        System.out.println(SEPARATOR_THIN);
        System.out.println(testName);
        System.out.println(SEPARATOR_THIN);
        System.out.println();
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 1 && Executor.RUNNER_ARG.equals(args[0])) {
            // Executed by a test-launched JVM.
            // Force the initialization of java.security.Security.
            Security.getProviders();
            Security.setProperty("postInitTest", "shouldNotRecord");
            System.out.println(FilesManager.LAST_FILE_PROP_NAME + ": " +
                    Security.getProperty(FilesManager.LAST_FILE_PROP_NAME));
            assertTestSecuritySetPropertyShouldNotInclude();
        } else {
            // Executed by the test JVM.
            try (FilesManager filesMgr = new FilesManager()) {
                for (Method m : ConfigFileTest.class.getDeclaredMethods()) {
                    if (m.getName().startsWith("test")) {
                        printTestHeader(m.getName());
                        Executor.run(m, filesMgr);
                    }
                }
            }
        }
    }

    /*
     * Success cases
     */

    static void testShowSettings(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Sanity test passing the -XshowSettings:security option.
        ex.addJvmArg("-XshowSettings:security");
        ex.setMasterFile(filesMgr.newMasterFile());
        ex.assertSuccess();
        ex.getOutputAnalyzer()
                .shouldContain("Security properties:")
                .shouldContain("Security provider static configuration:")
                .shouldContain("Security TLS configuration");
    }

    static void testIncludeBasic(Executor ex, FilesManager filesMgr)
            throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        ExtraPropsFile extraFile = filesMgr.newExtraFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");
        PropsFile file1 = filesMgr.newFile("dir1/file1.properties");
        PropsFile file2 = filesMgr.newFile("dir1/dir2/file2.properties");

        masterFile.addAbsoluteInclude(file0);
        extraFile.addRelativeInclude(file2);
        file2.addAbsoluteInclude(file1);

        ex.setMasterFile(masterFile);
        ex.setExtraFile(extraFile, Executor.ExtraMode.FILE_URI, false);
        ex.assertSuccess();
    }

    static void testRepeatedInclude(Executor ex, FilesManager filesMgr)
            throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");
        PropsFile file1 = filesMgr.newFile("dir1/file1.properties");

        masterFile.addAbsoluteInclude(file0);
        masterFile.addAbsoluteInclude(file1);
        masterFile.addAbsoluteInclude(file0);
        file1.addRelativeInclude(file0);

        ex.setMasterFile(masterFile);
        ex.assertSuccess();
    }

    static void testIncludeWithOverrideAll(Executor ex, FilesManager filesMgr)
            throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        ExtraPropsFile extraFile = filesMgr.newExtraFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");
        PropsFile file1 = filesMgr.newFile("dir1/file1.properties");

        masterFile.addRelativeInclude(file0);
        extraFile.addAbsoluteInclude(file1);

        ex.setMasterFile(masterFile);
        ex.setExtraFile(extraFile, Executor.ExtraMode.HTTP_SERVED, true);
        ex.assertSuccess();
    }

    static void extraPropertiesByHelper(Executor ex, FilesManager filesMgr,
            Executor.ExtraMode mode) throws Exception {
        ExtraPropsFile extraFile = filesMgr.newExtraFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");

        extraFile.addRelativeInclude(file0);

        ex.setMasterFile(filesMgr.newMasterFile());
        ex.setExtraFile(extraFile, mode, true);
        ex.assertSuccess();
    }

    static void testExtraPropertiesByPathAbsolute(Executor ex,
            FilesManager filesMgr) throws Exception {
        extraPropertiesByHelper(ex, filesMgr, Executor.ExtraMode.PATH_ABS);
    }

    static void testExtraPropertiesByPathRelative(Executor ex,
            FilesManager filesMgr) throws Exception {
        extraPropertiesByHelper(ex, filesMgr, Executor.ExtraMode.PATH_REL);
    }

    static void specialCharsIncludes(Executor ex, FilesManager filesMgr,
            char specialChar, Executor.ExtraMode extraMode,
            boolean useRelativeIncludes) throws Exception {
        String suffix = specialChar + ".properties";
        ExtraPropsFile extraFile;
        PropsFile file0, file1;
        try {
            extraFile = filesMgr.newExtraFile("extra" + suffix);
            file0 = filesMgr.newFile("file0" + suffix);
            file1 = filesMgr.newFile("file1" + suffix);
        } catch (InvalidPathException ipe) {
            // The platform encoding may not allow to create files with some
            // special characters. Skip the test in these cases.
            return;
        }

        if (useRelativeIncludes) {
            extraFile.addRelativeInclude(file0);
        } else {
            extraFile.addAbsoluteInclude(file0);
        }
        extraFile.addAbsoluteInclude(file1);

        ex.setMasterFile(filesMgr.newMasterFile());
        ex.setExtraFile(extraFile, extraMode, false);
        ex.assertSuccess();
    }

    static void testUnicodeIncludes1(Executor ex, FilesManager filesMgr)
            throws Exception {
        specialCharsIncludes(ex, filesMgr, '\u2022',
                Executor.ExtraMode.PATH_ABS, true);
    }

    static void testUnicodeIncludes2(Executor ex, FilesManager filesMgr)
            throws Exception {
        specialCharsIncludes(ex, filesMgr, '\u2022',
                Executor.ExtraMode.FILE_URI, true);
    }

    static void testUnicodeIncludes3(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Backward compatibility check. Malformed URLs such as
        // file:/tmp/extra•.properties are supported for the extra file.
        // However, relative includes are not allowed in these cases.
        specialCharsIncludes(ex, filesMgr, '\u2022',
                Executor.ExtraMode.RAW_FILE_URI1, false);
    }

    static void testUnicodeIncludes4(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Backward compatibility check. Malformed URLs such as
        // file:///tmp/extra•.properties are supported for the extra file.
        // However, relative includes are not allowed in these cases.
        specialCharsIncludes(ex, filesMgr, '\u2022',
                Executor.ExtraMode.RAW_FILE_URI2, false);
    }

    static void testSpaceIncludes1(Executor ex, FilesManager filesMgr)
            throws Exception {
        specialCharsIncludes(ex, filesMgr, ' ',
                Executor.ExtraMode.PATH_ABS, true);
    }

    static void testSpaceIncludes2(Executor ex, FilesManager filesMgr)
            throws Exception {
        specialCharsIncludes(ex, filesMgr, ' ',
                Executor.ExtraMode.FILE_URI, true);
    }

    static void testSpaceIncludes3(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Backward compatibility check. Malformed URLs such as
        // file:/tmp/extra .properties are supported for the extra file.
        // However, relative includes are not allowed in these cases.
        specialCharsIncludes(ex, filesMgr, ' ',
                Executor.ExtraMode.RAW_FILE_URI1, false);
    }

    static void testSpaceIncludes4(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Backward compatibility check. Malformed URLs such as
        // file:///tmp/extra .properties are supported for the extra file.
        // However, relative includes are not allowed in these cases.
        specialCharsIncludes(ex, filesMgr, ' ',
                Executor.ExtraMode.RAW_FILE_URI2, false);
    }

    static void notOverrideOnFailureHelper(Executor ex, FilesManager filesMgr,
            String nonExistentExtraFile) throws Exception {
        // An overriding extra properties file that does not exist
        // should not erase properties from the master file.
        ex.setIgnoredExtraFile(nonExistentExtraFile, true);
        ex.setMasterFile(filesMgr.newMasterFile());
        ex.assertSuccess();
        ex.getOutputAnalyzer().shouldContain("unable to load security " +
                "properties from " + nonExistentExtraFile);
    }

    static void testNotOverrideOnEmptyFailure(Executor ex,
            FilesManager filesMgr) throws Exception {
        notOverrideOnFailureHelper(ex, filesMgr, "");
        ex.getOutputAnalyzer()
                .shouldContain("Empty extra properties file path");
    }

    static void testNotOverrideOnURLFailure(Executor ex, FilesManager filesMgr)
            throws Exception {
        notOverrideOnFailureHelper(ex, filesMgr,
                "file:///nonExistentFile.properties");
    }

    static void testNotOverrideOnPathFailure(Executor ex, FilesManager filesMgr)
            throws Exception {
        notOverrideOnFailureHelper(ex, filesMgr, "nonExistentFile.properties");
    }

    static void testNotOverrideOnDirFailure(Executor ex, FilesManager filesMgr)
            throws Exception {
        notOverrideOnFailureHelper(ex, filesMgr, "file:///");
        ex.getOutputAnalyzer().shouldContain("Is a directory");
    }

    static void testNotOverrideOnBadFileURLFailure(Executor ex,
            FilesManager filesMgr) throws Exception {
        notOverrideOnFailureHelper(ex, filesMgr, "file:///%00");
    }

    static void testDisabledExtraPropertiesFile(Executor ex,
            FilesManager filesMgr) throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");

        masterFile.addRawProperty("security.overridePropertiesFile", "false");

        ex.setMasterFile(masterFile);
        ex.setIgnoredExtraFile(file0.path.toString(), true);
        ex.assertSuccess();
    }

    static final String SECURITY_SET_PROP_FILE_PATH =
            "testSecuritySetPropertyShouldNotInclude.propsFilePath";

    static void testSecuritySetPropertyShouldNotInclude(Executor ex,
            FilesManager filesMgr) throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");

        ex.addSystemProp(SECURITY_SET_PROP_FILE_PATH, file0.path.toString());
        ex.setMasterFile(masterFile);
        ex.assertSuccess();
    }

    static void assertTestSecuritySetPropertyShouldNotInclude() {
        // This check is executed by the launched JVM.
        String propsFilePath = System.getProperty(SECURITY_SET_PROP_FILE_PATH);
        if (propsFilePath != null) {
            String name = Path.of(propsFilePath).getFileName().toString();
            String setPropInvokeRepr = "Security.setProperty(\"include\", " +
                    "\"" + propsFilePath + "\")";
            try {
                Security.setProperty("include", propsFilePath);
                throw new RuntimeException(setPropInvokeRepr + " was " +
                        "expected to throw IllegalArgumentException.");
            } catch (IllegalArgumentException expected) {}
            if (FilesManager.APPLIED_PROP_VALUE.equals(
                    Security.getProperty(name))) {
                throw new RuntimeException(setPropInvokeRepr + " caused " +
                        "a file inclusion.");
            }
            try {
                Security.getProperty("include");
                throw new RuntimeException("Security.getProperty(\"include\")" +
                        " was expected to throw IllegalArgumentException.");
            } catch (IllegalArgumentException expected) {}
        }
    }

    /*
     * Error cases
     */

    static void testCannotResolveRelativeFromHTTPServed(Executor ex,
            FilesManager filesMgr) throws Exception {
        ExtraPropsFile extraFile = filesMgr.newExtraFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");

        extraFile.addRelativeInclude(file0);

        ex.setMasterFile(filesMgr.newMasterFile());
        ex.setExtraFile(extraFile, Executor.ExtraMode.HTTP_SERVED, true);
        ex.assertError("InternalError: Cannot resolve '" + file0.fileName +
                "' relative path when included from a non-regular " +
                "properties file (e.g. HTTP served file)");
    }

    static void testCannotIncludeCycles(Executor ex, FilesManager filesMgr)
            throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        PropsFile file0 = filesMgr.newFile("file0.properties");
        PropsFile file1 = filesMgr.newFile("dir1/file1.properties");

        // Includes chain: master -> file0 -> file1 -> master.
        file1.addRelativeInclude(masterFile);
        file0.addRelativeInclude(file1);
        masterFile.addRelativeInclude(file0);

        ex.setMasterFile(masterFile);
        ex.assertError(
                "InternalError: Cyclic include of '" + masterFile.path + "'");
    }

    static void testCannotIncludeURL(Executor ex, FilesManager filesMgr)
            throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();
        ExtraPropsFile extraFile = filesMgr.newExtraFile();

        masterFile.addRawProperty("include", extraFile.url.toString());

        ex.setMasterFile(masterFile);
        ex.assertError("InternalError: Unable to include 'http://127.0.0.1:");
    }

    static void testCannotIncludeNonexistentFile(Executor ex,
            FilesManager filesMgr) throws Exception {
        PropsFile masterFile = filesMgr.newMasterFile();

        String nonexistentPath = "/nonExistentFile.properties";
        masterFile.addRawProperty("include", nonexistentPath);

        ex.setMasterFile(masterFile);
        ex.assertError(
                "InternalError: Unable to include '" + nonexistentPath + "'");
    }

    static void testMustHaveMasterFile(Executor ex, FilesManager filesMgr)
            throws Exception {
        // Launch a JDK without a master java.security file present.
        ex.assertError("InternalError: Error loading java.security file");
    }

    static void testMustHaveMasterFileEvenWithExtraFile(Executor ex,
            FilesManager filesMgr) throws Exception {
        // Launch a JDK without a master java.security file present, but with an
        // extra file passed. Since the "security.overridePropertiesFile=true"
        // security property is missing, it should fail anyway.
        ex.setExtraFile(
                filesMgr.newExtraFile(), Executor.ExtraMode.FILE_URI, true);
        ex.assertError("InternalError: Error loading java.security file");
    }
}

sealed class PropsFile permits ExtraPropsFile {
    protected static final class Include {
        final PropsFile propsFile;
        final String value;

        private Include(PropsFile propsFile, String value) {
            this.propsFile = propsFile;
            this.value = value;
        }

        static Include of(PropsFile propsFile) {
            return new Include(propsFile, propsFile.path.toString());
        }

        static Include of(PropsFile propsFile, String value) {
            return new Include(propsFile, value);
        }
    }

    protected final List<Include> includes = new ArrayList<>();
    protected final PrintWriter writer;
    protected boolean includedFromExtra = false;
    final String fileName;
    final Path path;

    PropsFile(String fileName, Path path) throws IOException {
        this.fileName = fileName;
        this.path = path;
        this.writer = new PrintWriter(Files.newOutputStream(path,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND), true);
    }

    private static String escape(String text, boolean escapeSpace) {
        StringBuilder sb = new StringBuilder(text.length());
        CharBuffer cb = CharBuffer.wrap(text);
        while (cb.hasRemaining()) {
            char c = cb.get();
            if (c == '\\' || escapeSpace && c == ' ') {
                sb.append('\\');
            }
            if (Character.UnicodeBlock.of(c) ==
                    Character.UnicodeBlock.BASIC_LATIN) {
                sb.append(c);
            } else {
                sb.append("\\u%04x".formatted((int) c));
            }
        }
        return sb.toString();
    }

    private void addRawProperty(String key, String value, String sep) {
        writer.println(escape(key, true) + sep + escape(value, false));
    }

    protected void addIncludeDefinition(Include include) {
        if (include.propsFile instanceof ExtraPropsFile) {
            throw new RuntimeException("ExtraPropsFile should not be included");
        }
        includes.add(include);
        addRawProperty("include", include.value, " ");
    }

    void addComment(String comment) {
        writer.println("# " + comment);
    }

    void addRawProperty(String key, String value) {
        addRawProperty(key, value, "=");
    }

    void addAbsoluteInclude(PropsFile propsFile) {
        addIncludeDefinition(Include.of(propsFile));
    }

    void addRelativeInclude(PropsFile propsFile) {
        addIncludeDefinition(Include.of(propsFile,
                path.getParent().relativize(propsFile.path).toString()));
    }

    void assertApplied(OutputAnalyzer oa) {
        oa.shouldContain(Executor.INITIAL_PROP_LOG_MSG + fileName + "=" +
                FilesManager.APPLIED_PROP_VALUE);
        for (Include include : includes) {
            include.propsFile.assertApplied(oa);
            oa.shouldContain("processing include: '" + include.value + "'");
            oa.shouldContain("finished processing " + include.propsFile.path);
        }
    }

    void assertWasOverwritten(OutputAnalyzer oa) {
        oa.shouldNotContain(Executor.INITIAL_PROP_LOG_MSG + fileName + "=" +
                FilesManager.APPLIED_PROP_VALUE);
        for (Include include : includes) {
            if (!include.propsFile.includedFromExtra) {
                include.propsFile.assertWasOverwritten(oa);
            }
            oa.shouldContain("processing include: '" + include.value + "'");
            oa.shouldContain("finished processing " + include.propsFile.path);
        }
    }

    void markAsIncludedFromExtra() {
        includedFromExtra = true;
        for (Include include : includes) {
            include.propsFile.markAsIncludedFromExtra();
        }
    }

    PropsFile getLastFile() {
        return includes.isEmpty() ?
                this : includes.getLast().propsFile.getLastFile();
    }

    void close() {
        writer.close();
    }
}

final class ExtraPropsFile extends PropsFile {
    private final Map<String, String> systemProps = new LinkedHashMap<>();
    final URI url;

    ExtraPropsFile(String fileName, URI url, Path path) throws IOException {
        super(fileName, path);
        this.url = url;
    }

    @Override
    protected void addIncludeDefinition(Include include) {
        if (includes.isEmpty()) {
            String propName = "props.fileName";
            systemProps.put(propName, include.propsFile.fileName);
            include = Include.of(include.propsFile,
                    include.value.replace(include.propsFile.fileName,
                            "${props.none}${" + propName + "}"));
        }
        include.propsFile.markAsIncludedFromExtra();
        super.addIncludeDefinition(include);
    }

    Map<String, String> getSystemProperties() {
        return Collections.unmodifiableMap(systemProps);
    }
}

final class FilesManager implements Closeable {
    private static final Path ROOT_DIR =
            Path.of(ConfigFileTest.class.getSimpleName()).toAbsolutePath();
    private static final Path PROPS_DIR = ROOT_DIR.resolve("properties");
    private static final Path JDK_DIR = ROOT_DIR.resolve("jdk");
    private static final Path MASTER_FILE =
            JDK_DIR.resolve("conf/security/java.security");
    private static final Path MASTER_FILE_TEMPLATE =
            MASTER_FILE.resolveSibling("java.security.template");
    static final String JAVA_EXECUTABLE =
            JDK_DIR.resolve("bin/java").toString();
    static final String LAST_FILE_PROP_NAME = "last-file";
    static final String APPLIED_PROP_VALUE = "applied";

    private final List<PropsFile> createdFiles;
    private final Set<String> fileNamesInUse;
    private final HttpServer httpServer;
    private final URI serverUri;
    private final long masterFileLines;

    FilesManager() throws Exception {
        createdFiles = new ArrayList<>();
        fileNamesInUse = new HashSet<>();
        httpServer = HttpServer.create(
                new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        httpServer.createContext("/", this::handleRequest);
        InetSocketAddress address = httpServer.getAddress();
        httpServer.start();
        serverUri = new URI("http", null, address.getHostString(),
                address.getPort(), null, null, null);
        copyJDK();
        try (Stream<String> s = Files.lines(MASTER_FILE_TEMPLATE)) {
            masterFileLines = s.count();
        }
    }

    private static void copyJDK() throws Exception {
        Path testJDK = Path.of(Objects.requireNonNull(
                System.getProperty("test.jdk"), "unspecified test.jdk"));
        if (!Files.exists(testJDK)) {
            throw new RuntimeException("test.jdk -> nonexistent JDK");
        }
        Files.createDirectories(JDK_DIR);
        try (Stream<Path> pathStream = Files.walk(testJDK)) {
            pathStream.skip(1).forEach((Path file) -> {
                try {
                    Files.copy(file, JDK_DIR.resolve(testJDK.relativize(file)),
                            StandardCopyOption.COPY_ATTRIBUTES);
                } catch (IOException ioe) {
                    throw new UncheckedIOException(ioe);
                }
            });
        }
        Files.move(MASTER_FILE, MASTER_FILE_TEMPLATE);
    }

    private void handleRequest(HttpExchange x) throws IOException {
        String rawPath = x.getRequestURI().getRawPath();
        Path f = ROOT_DIR.resolve(x.getRequestURI().getPath().substring(1));
        int statusCode;
        byte[] responseBody;
        // Check for unescaped space, unresolved parent or backward slash.
        if (rawPath.matches("^.*( |(\\.|%2[Ee]){2}|\\\\|%5[Cc]).*$")) {
            statusCode = HttpURLConnection.HTTP_BAD_REQUEST;
            responseBody = new byte[0];
        } else if (Files.isRegularFile(f)) {
            x.getResponseHeaders().add("Content-type", "text/plain");
            statusCode = HttpURLConnection.HTTP_OK;
            responseBody = Files.readAllBytes(f);
        } else {
            statusCode = HttpURLConnection.HTTP_NOT_FOUND;
            responseBody = new byte[0];
        }
        System.out.println("[" + Instant.now() + "] " +
                getClass().getSimpleName() + ": " +
                x.getRequestMethod() + " " + rawPath + " -> " +
                statusCode + " (" + responseBody.length + " bytes)");
        try (OutputStream responseStream = x.getResponseBody()) {
            x.sendResponseHeaders(statusCode, responseBody.length);
            responseStream.write(responseBody);
        }
    }

    @FunctionalInterface
    private interface PropsFileBuilder {
        PropsFile build(String fileName, Path path) throws IOException;
    }

    private PropsFile newFile(Path path, PropsFileBuilder builder)
            throws IOException {
        String fileName = path.getFileName().toString();
        if (!fileNamesInUse.add(fileName)) {
            // Names must be unique in order for the special
            // property <fileName>=<APPLIED_PROP_VALUE> to work.
            throw new RuntimeException(fileName + " is repeated");
        }
        Files.createDirectories(path.getParent());
        PropsFile propsFile = builder.build(fileName, path);
        propsFile.addComment("Property to determine if this properties file " +
                "was parsed and not overwritten:");
        propsFile.addRawProperty(fileName, APPLIED_PROP_VALUE);
        propsFile.addComment(ConfigFileTest.SEPARATOR_THIN);
        propsFile.addComment("Property to be overwritten by every properties " +
                "file (master, extra or included):");
        propsFile.addRawProperty(LAST_FILE_PROP_NAME, fileName);
        propsFile.addComment(ConfigFileTest.SEPARATOR_THIN);
        createdFiles.add(propsFile);
        return propsFile;
    }

    PropsFile newFile(String relPathStr) throws IOException {
        return newFile(PROPS_DIR.resolve(relPathStr), PropsFile::new);
    }

    PropsFile newMasterFile() throws IOException {
        Files.copy(MASTER_FILE_TEMPLATE, MASTER_FILE);
        return newFile(MASTER_FILE, PropsFile::new);
    }

    ExtraPropsFile newExtraFile() throws IOException {
        return newExtraFile("extra.properties");
    }

    ExtraPropsFile newExtraFile(String extraFileName) throws IOException {
        return (ExtraPropsFile) newFile(PROPS_DIR.resolve(extraFileName),
                (fileName, path) -> {
                    URI uri = serverUri.resolve(ParseUtil.encodePath(
                            ROOT_DIR.relativize(path).toString()));
                    return new ExtraPropsFile(fileName, uri, path);
                });
    }

    void reportCreatedFiles() throws IOException {
        for (PropsFile propsFile : createdFiles) {
            System.err.println();
            System.err.println(propsFile.path.toString());
            System.err.println(ConfigFileTest.SEPARATOR_THIN.repeat(3));
            try (Stream<String> lines = Files.lines(propsFile.path)) {
                long lineNumber = 1L;
                Iterator<String> it = lines.iterator();
                while (it.hasNext()) {
                    String line = it.next();
                    if (!propsFile.path.equals(MASTER_FILE) ||
                            lineNumber > masterFileLines) {
                        System.err.println(line);
                    }
                    lineNumber++;
                }
            }
            System.err.println();
        }
    }

    void clear() throws IOException {
        if (!createdFiles.isEmpty()) {
            for (PropsFile propsFile : createdFiles) {
                propsFile.close();
                Files.delete(propsFile.path);
            }
            FileUtils.deleteFileTreeUnchecked(PROPS_DIR);
            createdFiles.clear();
            fileNamesInUse.clear();
        }
    }

    @Override
    public void close() throws IOException {
        clear();
        httpServer.stop(0);
        FileUtils.deleteFileTreeUnchecked(ROOT_DIR);
    }
}

final class Executor {
    enum ExtraMode {
        HTTP_SERVED, FILE_URI, RAW_FILE_URI1, RAW_FILE_URI2, PATH_ABS, PATH_REL
    }
    static final String RUNNER_ARG = "runner";
    static final String INITIAL_PROP_LOG_MSG = "Initial security property: ";
    private static final String OVERRIDING_LOG_MSG =
            "overriding other security properties files!";
    private static final String[] ALWAYS_UNEXPECTED_LOG_MSGS = {
            "java.lang.AssertionError",
            INITIAL_PROP_LOG_MSG + "postInitTest=shouldNotRecord",
            INITIAL_PROP_LOG_MSG + "include=",
    };
    private static final Path CWD = Path.of(".").toAbsolutePath();
    private static final String JAVA_SEC_PROPS = "java.security.properties";
    private static final String CLASS_PATH = Objects.requireNonNull(
            System.getProperty("test.classes"), "unspecified test.classes");
    private static final String DEBUG_ARG =
            "-Xrunjdwp:transport=dt_socket,address=localhost:8000,suspend=y";
    private final Map<String, String> systemProps = new LinkedHashMap<>(
            Map.of("java.security.debug", "all", "javax.net.debug", "all",
                    // Ensure we get UTF-8 debug outputs in Windows:
                    "stderr.encoding", "UTF-8", "stdout.encoding", "UTF-8"));
    private final List<String> jvmArgs = new ArrayList<>(
            List.of(FilesManager.JAVA_EXECUTABLE, "-enablesystemassertions",
                    // Uncomment DEBUG_ARG to debug test-launched JVMs:
                    "-classpath", CLASS_PATH//, DEBUG_ARG
            ));
    private PropsFile masterPropsFile;
    private ExtraPropsFile extraPropsFile;
    private boolean expectedOverrideAll = false;
    private OutputAnalyzer oa;

    static void run(Method m, FilesManager filesMgr) throws Exception {
        try {
            m.invoke(null, new Executor(), filesMgr);
        } catch (Throwable e) {
            filesMgr.reportCreatedFiles();
            throw e;
        } finally {
            filesMgr.clear();
        }
    }

    void addSystemProp(String key, String value) {
        systemProps.put(key, value);
    }

    private void setRawExtraFile(String extraFile, boolean overrideAll) {
        addSystemProp(JAVA_SEC_PROPS, (overrideAll ? "=" : "") + extraFile);
    }

    void setMasterFile(PropsFile masterPropsFile) {
        this.masterPropsFile = masterPropsFile;
    }

    void setExtraFile(ExtraPropsFile extraPropsFile, ExtraMode mode,
            boolean overrideAll) {
        this.extraPropsFile = extraPropsFile;
        expectedOverrideAll = overrideAll;
        setRawExtraFile(switch (mode) {
            case HTTP_SERVED -> extraPropsFile.url.toString();
            case FILE_URI -> extraPropsFile.path.toUri().toString();
            case RAW_FILE_URI1 -> "file:" + extraPropsFile.path;
            case RAW_FILE_URI2 -> "file://" +
                    (extraPropsFile.path.startsWith("/") ? "" : "/") +
                    extraPropsFile.path;
            case PATH_ABS -> extraPropsFile.path.toString();
            case PATH_REL -> CWD.relativize(extraPropsFile.path).toString();
        }, overrideAll);
    }

    void setIgnoredExtraFile(String extraPropsFile, boolean overrideAll) {
        setRawExtraFile(extraPropsFile, overrideAll);
        expectedOverrideAll = false;
    }

    void addJvmArg(String arg) {
        jvmArgs.add(arg);
    }

    private void execute(boolean successExpected) throws Exception {
        List<String> command = new ArrayList<>(jvmArgs);
        Collections.addAll(command, Utils.getTestJavaOpts());
        addSystemPropertiesAsJvmArgs(command);
        command.add(ConfigFileTest.class.getSimpleName());
        command.add(RUNNER_ARG);
        oa = ProcessTools.executeProcess(new ProcessBuilder(command));
        oa.shouldHaveExitValue(successExpected ? 0 : 1);
        for (String output : ALWAYS_UNEXPECTED_LOG_MSGS) {
            oa.shouldNotContain(output);
        }
    }

    private void addSystemPropertiesAsJvmArgs(List<String> command) {
        Map<String, String> allSystemProps = new LinkedHashMap<>(systemProps);
        if (extraPropsFile != null) {
            allSystemProps.putAll(extraPropsFile.getSystemProperties());
        }
        for (Map.Entry<String, String> e : allSystemProps.entrySet()) {
            command.add("-D" + e.getKey() + "=" + e.getValue());
        }
    }

    void assertSuccess() throws Exception {
        execute(true);

        // Ensure every file was processed by checking a unique property used as
        // a flag. Each file defines <fileName>=applied.
        //
        // For example:
        //
        //   file0
        //   ---------------
        //   file0=applied
        //   include file1
        //
        //   file1
        //   ---------------
        //   file1=applied
        //
        // The assertion would be file0 == applied AND file1 == applied.
        //
        if (extraPropsFile != null) {
            extraPropsFile.assertApplied(oa);
        }
        if (expectedOverrideAll) {
            // When overriding with an extra file, check that neither
            // the master file nor its includes are visible.
            oa.shouldContain(OVERRIDING_LOG_MSG);
            masterPropsFile.assertWasOverwritten(oa);
        } else {
            oa.shouldNotContain(OVERRIDING_LOG_MSG);
            masterPropsFile.assertApplied(oa);
        }

        // Ensure the last included file overwrote a fixed property. Each file
        // defines last-file=<fileName>.
        //
        // For example:
        //
        //   file0
        //   ---------------
        //   last-file=file0
        //   include file1
        //
        //   file1
        //   ---------------
        //   last-file=file1
        //
        // The assertion would be last-file == file1.
        //
        PropsFile lastFile = (extraPropsFile == null ?
                masterPropsFile : extraPropsFile).getLastFile();
        oa.shouldContain(FilesManager.LAST_FILE_PROP_NAME + "=" +
                lastFile.fileName);
        oa.stdoutShouldContain(FilesManager.LAST_FILE_PROP_NAME + ": " +
                lastFile.fileName);
    }

    void assertError(String message) throws Exception {
        execute(false);
        oa.shouldContain(message);
    }

    OutputAnalyzer getOutputAnalyzer() {
        return oa;
    }
}