ExecutableFinder.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.maven.impl.model.profile;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;

import org.apache.maven.api.services.model.ProfileActivationContext;

/**
 * Helper that implements the OS-aware PATH search used by the {@code executable()} condition function.
 *
 * <p>The search strategy is:
 * <ol>
 *   <li>If {@code name} contains a path separator (i.e. it already looks like a path), treat it as
 *       an absolute or relative file path and check it directly.</li>
 *   <li>Otherwise, retrieve the {@code PATH} value from the activation context's system properties
 *       (Maven normalises env vars to {@code env.PATH} / {@code env.Path} etc.) and split it by the
 *       platform path separator.  Each directory is searched in order.</li>
 *   <li>On Windows, when the candidate does not already have one of the known executable extensions
 *       ({@code .exe}, {@code .cmd}, {@code .bat}, {@code .com}), those extensions are appended and
 *       tried as well.</li>
 * </ol>
 *
 * @since 4.x
 */
class ExecutableFinder {

    /** Windows-specific executable file extensions, in search order. */
    private static final String[] WINDOWS_EXTENSIONS = {".exe", ".cmd", ".bat", ".com"};

    /** The system property key under which Maven exposes the {@code PATH} environment variable. */
    private static final String ENV_PATH_KEY = "env.PATH";

    private ExecutableFinder() {}

    /**
     * Returns {@code true} when {@code name} resolves to an executable file.
     *
     * @param name    the executable name (e.g. {@code "musl-gcc"}) or an absolute/relative path
     * @param context the current profile activation context
     * @return {@code true} if the executable is found and is a regular, executable file
     */
    static boolean isExecutableInPath(String name, ProfileActivationContext context) {
        boolean isWindows = isWindows(context);

        // If the name already contains a path separator treat it as a direct path.
        if (name.contains("/") || name.contains(File.separator)) {
            Path candidate = Path.of(name);
            if (isExecutableFile(candidate, isWindows)) {
                return true;
            }
            // On Windows also try known executable extensions for direct paths.
            if (isWindows && !hasWindowsExtension(name)) {
                for (String ext : WINDOWS_EXTENSIONS) {
                    if (isExecutableFile(Path.of(name + ext), isWindows)) {
                        return true;
                    }
                }
            }
            return false;
        }

        // --- plain name: search PATH ---
        String pathValue = getPathValue(context);
        if (pathValue == null || pathValue.isBlank()) {
            return false;
        }

        String[] dirs = pathValue.split(File.pathSeparator, -1);
        for (String dir : dirs) {
            if (dir.isBlank()) {
                continue;
            }
            Path base = Path.of(dir).resolve(name);
            if (isExecutableFile(base, isWindows)) {
                return true;
            }
            // On Windows also try known executable extensions (unless already present).
            if (isWindows && !hasWindowsExtension(name)) {
                for (String ext : WINDOWS_EXTENSIONS) {
                    Path withExt = Path.of(dir).resolve(name + ext);
                    if (isExecutableFile(withExt, isWindows)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    // -----------------------------------------------------------------------
    // Package-private helpers (visible to tests)
    // -----------------------------------------------------------------------

    /**
     * Retrieves the PATH value from the activation context.
     *
     * <p>Maven places env vars in system properties as {@code env.<NAME>}.
     * On Windows, env var names are normalised to upper-case (e.g. {@code env.PATH}).
     *
     * @param context the profile activation context
     * @return the raw PATH string, or {@code null} if not available
     */
    static String getPathValue(ProfileActivationContext context) {
        return context.getSystemProperty(ENV_PATH_KEY);
    }

    // -----------------------------------------------------------------------
    // Private utilities
    // -----------------------------------------------------------------------

    private static boolean isWindows(ProfileActivationContext context) {
        String osName = context.getSystemProperty("os.name");
        return osName != null && osName.toLowerCase(Locale.ROOT).contains("windows");
    }

    /**
     * Returns {@code true} if {@code path} is a regular file that the JVM considers executable.
     *
     * <p>On Windows, {@link Files#isExecutable(Path)} always returns {@code true} for regular
     * files, so this method simply checks that the file exists and is regular.  The caller is
     * responsible for probing Windows executable extensions ({@code .exe}, {@code .cmd}, etc.)
     * when the user-supplied name does not already carry one.  On Unix/POSIX systems the
     * execute permission bit is checked via {@link Files#isExecutable(Path)}.</p>
     */
    private static boolean isExecutableFile(Path path, boolean isWindows) {
        if (!Files.isRegularFile(path)) {
            return false;
        }
        // On Windows Files.isExecutable() always returns true for regular files.
        // On Unix we rely on the execute permission bit.
        return isWindows || Files.isExecutable(path);
    }

    private static boolean hasWindowsExtension(String name) {
        String lower = name.toLowerCase(Locale.ROOT);
        for (String ext : WINDOWS_EXTENSIONS) {
            if (lower.endsWith(ext)) {
                return true;
            }
        }
        return false;
    }
}