diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java index 8cc8cb3459b3..1a470669d13e 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java @@ -713,6 +713,17 @@ private void validate30RawProfileActivation(ModelProblemCollector problems, Acti return; } + if (activation.getCondition() != null && activation.getCondition().contains("executable(")) { + addViolation( + problems, + Severity.WARNING, + Version.V40, + prefix + "activation.condition", + null, + "Profile activation relies on the 'executable' function, which makes the profile activation environment-dependent. This means the published POM will not be reproducible. Consider using this function only in local build profiles or stripping it before publication.", + activation.getLocation("condition")); + } + final Deque stk = new LinkedList<>(); final Supplier pathSupplier = () -> { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ConditionFunctions.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ConditionFunctions.java index 1a9087273962..6dce84ee8853 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ConditionFunctions.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ConditionFunctions.java @@ -237,4 +237,40 @@ public Object inrange(List args) { String range = ConditionParser.toString(args.get(1)); return versionParser.parseVersionRange(range).contains(versionParser.parseVersion(version)); } + + /** + * Checks whether a given executable can be found in the system PATH, or – if an + * absolute / relative path is supplied – whether that path itself is an executable file. + * + *

Warning: relying on local system environment variables like PATH makes + * profile activation non-reproducible. This function should typically be used only in local + * build profiles and not in consumer POMs that are published to a remote repository.

+ * + *

Usage examples in a profile {@code }: + *

+     *   executable('musl-gcc')
+     *   executable('x86_64-linux-musl-gcc')
+     *   executable('/usr/bin/musl-gcc')
+     * 
+ * + *

When a plain name (without path separators) is given the function searches every + * directory listed in the {@code PATH} environment variable. On Windows, the platform + * executable extensions ({@code .exe}, {@code .cmd}, {@code .bat}, {@code .com}) are + * tried automatically when the name does not already carry an extension. + * + * @param args A list containing a single string argument: the executable name or path + * @return {@code true} if the executable is found and is a regular, executable file, + * {@code false} otherwise + * @throws IllegalArgumentException if the number of arguments is not exactly one + */ + public Object executable(List args) { + if (args.size() != 1) { + throw new IllegalArgumentException("executable function requires exactly one argument"); + } + String name = ConditionParser.toString(args.get(0)); + if (name == null || name.isBlank()) { + return false; + } + return ExecutableFinder.isExecutableInPath(name, context); + } } diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ExecutableFinder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ExecutableFinder.java new file mode 100644 index 000000000000..d004c86e25ae --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/ExecutableFinder.java @@ -0,0 +1,163 @@ +/* + * 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. + * + *

The search strategy is: + *

    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
  5. 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.
  6. + *
+ * + * @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. + * + *

Maven places env vars in system properties as {@code env.}. + * 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. + * + *

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)}.

+ */ + 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; + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ConditionProfileActivatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ConditionProfileActivatorTest.java index a47ffc32259f..017244f5979a 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ConditionProfileActivatorTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ConditionProfileActivatorTest.java @@ -490,4 +490,80 @@ protected ProfileActivationContext newFileContext(Path path) { protected ProfileActivationContext newFileContext() { return newFileContext(tempDir); } + + // ----------------------------------------------------------------------- + // executable() tests (MNG-8768) + // ----------------------------------------------------------------------- + + /** + * Puts a fake executable into a temporary directory and activates a profile only when + * that directory is on the PATH – confirming the PATH-search logic end-to-end. + */ + @Test + void testExecutablePresentInPath() throws Exception { + // Create a fake executable in the temp dir + Path fakeExec = tempDir.resolve("my-fake-tool"); + Files.createFile(fakeExec); + // Make it executable on POSIX systems; on Windows Files.isExecutable() returns true anyway + fakeExec.toFile().setExecutable(true); + + String pathValue = tempDir.toAbsolutePath().toString(); + Map sysProps = Map.of("env.PATH", pathValue); + + Profile profile = newProfile("executable('my-fake-tool')"); + assertActivation(true, profile, newContext(null, sysProps)); + } + + /** + * Verifies that the function returns false for a name that is definitely not on PATH. + */ + @Test + void testExecutableNotInPath() { + // Use an empty/nonexistent PATH so that nothing can be found + Map sysProps = Map.of("env.PATH", ""); + + Profile profile = newProfile("executable('this-tool-does-not-exist-anywhere-42')"); + assertActivation(false, profile, newContext(null, sysProps)); + } + + /** + * An absolute path to an existing executable file must resolve to true. + */ + @Test + void testExecutableWithAbsolutePath() throws Exception { + Path fakeExec = tempDir.resolve("abs-tool"); + Files.createFile(fakeExec); + fakeExec.toFile().setExecutable(true); + + String absPath = fakeExec.toAbsolutePath().toString(); + Profile profile = newProfile("executable('" + absPath + "')"); + + // PATH content does not matter for absolute paths + assertActivation(true, profile, newContext(null, Map.of("env.PATH", ""))); + } + + /** + * An absolute path to a non-existent file must resolve to false. + */ + @Test + void testExecutableAbsolutePathMissing() { + Profile profile = newProfile("executable('/no/such/executable/path/42/bin/tool')"); + assertActivation(false, profile, newContext(null, Map.of("env.PATH", ""))); + } + + /** + * not(executable(...)) must invert the result correctly. + */ + @Test + void testExecutableNegated() throws Exception { + Path fakeExec = tempDir.resolve("neg-tool"); + Files.createFile(fakeExec); + fakeExec.toFile().setExecutable(true); + + String pathValue = tempDir.toAbsolutePath().toString(); + Map sysProps = Map.of("env.PATH", pathValue); + + assertActivation(false, newProfile("not(executable('neg-tool'))"), newContext(null, sysProps)); + assertActivation(true, newProfile("not(executable('no-such-neg-tool'))"), newContext(null, sysProps)); + } } diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ExecutableFinderTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ExecutableFinderTest.java new file mode 100644 index 000000000000..3ada1a3fb5e8 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/profile/ExecutableFinderTest.java @@ -0,0 +1,228 @@ +/* + * 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.HashMap; +import java.util.Map; + +import org.apache.maven.api.services.model.ProfileActivationContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link ExecutableFinder}. + */ +class ExecutableFinderTest { + + @TempDir + Path tempDir; + + /** + * Minimal stub – only {@link ProfileActivationContext#getSystemProperty(String)} is used + * by {@link ExecutableFinder#getPathValue}. + */ + private static ProfileActivationContext contextWithPath(String pathValue) { + return contextWithPathAndOs(pathValue, null); + } + + /** + * Extended stub that also allows setting {@code os.name} for simulating Windows behaviour. + */ + private static ProfileActivationContext contextWithPathAndOs(String pathValue, String osName) { + Map props = new HashMap<>(); + if (pathValue != null) { + props.put("env.PATH", pathValue); + } + if (osName != null) { + props.put("os.name", osName); + } + return new ProfileActivationContext() { + @Override + public boolean isProfileActive(String profileId) { + return false; + } + + @Override + public boolean isProfileInactive(String profileId) { + return false; + } + + @Override + public String getSystemProperty(String key) { + return props.get(key); + } + + @Override + public String getUserProperty(String key) { + return null; + } + + @Override + public String getModelProperty(String key) { + return null; + } + + @Override + public String getModelArtifactId() { + return null; + } + + @Override + public String getModelPackaging() { + return null; + } + + @Override + public String getModelRootDirectory() { + return null; + } + + @Override + public String getModelBaseDirectory() { + return null; + } + + @Override + public String interpolatePath(String path) { + return path; + } + + @Override + public boolean exists(String path, boolean glob) { + return false; + } + }; + } + + // ----------------------------------------------------------------------- + // getPathValue() + // ----------------------------------------------------------------------- + + @Test + void getPathValueFromContext() { + String expected = "/usr/bin" + File.pathSeparator + "/usr/local/bin"; + ProfileActivationContext ctx = contextWithPath(expected); + assertEquals(expected, ExecutableFinder.getPathValue(ctx)); + } + + // ----------------------------------------------------------------------- + // isExecutableInPath() - plain name + // ----------------------------------------------------------------------- + + @Test + void findsExecutableByName() throws Exception { + Path exec = tempDir.resolve("my-tool"); + Files.createFile(exec); + exec.toFile().setExecutable(true); + + assertTrue(ExecutableFinder.isExecutableInPath("my-tool", contextWithPath(tempDir.toString()))); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void returnsFalseWhenFileIsNotExecutable() throws Exception { + Path exec = tempDir.resolve("non-exec-tool"); + Files.createFile(exec); + exec.toFile().setExecutable(false); + + // Only meaningful on POSIX; on Windows the execute bit is not enforced by the JVM. + assertFalse(ExecutableFinder.isExecutableInPath("non-exec-tool", contextWithPath(tempDir.toString()))); + } + + @Test + void returnsFalseWhenToolNotInPath() { + assertFalse(ExecutableFinder.isExecutableInPath( + "this-tool-definitely-does-not-exist-anywhere-12345", contextWithPath(tempDir.toString()))); + } + + @Test + void returnsFalseForEmptyPath() { + assertFalse(ExecutableFinder.isExecutableInPath("any-tool", contextWithPath(""))); + } + + // ----------------------------------------------------------------------- + // isExecutableInPath() - absolute / relative path + // ----------------------------------------------------------------------- + + @Test + void findsExecutableByAbsolutePath() throws Exception { + Path exec = tempDir.resolve("abs-exec"); + Files.createFile(exec); + exec.toFile().setExecutable(true); + + String absPath = exec.toAbsolutePath().toString(); + // Absolute paths contain path separators -> direct check + assertTrue(ExecutableFinder.isExecutableInPath(absPath, contextWithPath(""))); + } + + @Test + void returnsFalseForAbsolutePathThatDoesNotExist() { + assertFalse(ExecutableFinder.isExecutableInPath("/no/such/path/to/some/binary/42", contextWithPath(""))); + } + + @Test + void returnsFalseForDirectoryPath() throws Exception { + // Directories must not be accepted even if they exist. + assertFalse(ExecutableFinder.isExecutableInPath(tempDir.toAbsolutePath().toString(), contextWithPath(""))); + } + + // ----------------------------------------------------------------------- + // Windows extension probing (simulated via os.name in context) + // ----------------------------------------------------------------------- + + @Test + void findsExecutableWithWindowsExtensionInPath() throws Exception { + // Simulate Windows: create my-tool.exe and search for "my-tool" + Path exec = tempDir.resolve("my-tool.exe"); + Files.createFile(exec); + + ProfileActivationContext ctx = contextWithPathAndOs(tempDir.toString(), "Windows 10"); + assertTrue(ExecutableFinder.isExecutableInPath("my-tool", ctx)); + } + + @Test + void findsExecutableWithWindowsExtensionByDirectPath() throws Exception { + // Simulate Windows: create tool.exe and search for the direct path without extension + Path exec = tempDir.resolve("tool.exe"); + Files.createFile(exec); + + String directPath = tempDir.resolve("tool").toAbsolutePath().toString(); + ProfileActivationContext ctx = contextWithPathAndOs("", "Windows 10"); + assertTrue(ExecutableFinder.isExecutableInPath(directPath, ctx)); + } + + @Test + void doesNotAppendExtensionOnNonWindows() throws Exception { + // On non-Windows, searching for "my-tool" must NOT find "my-tool.exe" + Path exec = tempDir.resolve("my-tool.exe"); + Files.createFile(exec); + + ProfileActivationContext ctx = contextWithPathAndOs(tempDir.toString(), "Linux"); + assertFalse(ExecutableFinder.isExecutableInPath("my-tool", ctx)); + } +}