File: JavaBuildToolResolver.cs
Web Access
Project: src\src\Aspire.Hosting.Java\Aspire.Hosting.Java.csproj (Aspire.Hosting.Java)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Java;
 
/// <summary>
/// Resolves Java build tools from project files.
/// </summary>
internal static class JavaBuildToolResolver
{
    private static readonly string[] s_gradleBuildFileNames =
    [
        "build.gradle",
        "build.gradle.kts",
        "settings.gradle",
        "settings.gradle.kts"
    ];
 
    /// <summary>
    /// Returns the build tool declared by files in <paramref name="appDirectory"/>, or
    /// <see langword="null"/> when none is declared.
    /// </summary>
    internal static JavaBuildTool? Detect(
        string appDirectory,
        string resourceName,
        Func<string, Exception> createAmbiguityException)
    {
        var hasMaven = File.Exists(Path.Combine(appDirectory, "pom.xml"));
        var hasGradle = s_gradleBuildFileNames.Any(fileName => File.Exists(Path.Combine(appDirectory, fileName)));
 
        // Ambiguous projects are rejected rather than guessed. Maven-first detection made publish produce
        // a different artifact than run mode for the same directory, while an explicit build or launch API
        // records the author's choice for both paths.
        if (hasMaven && hasGradle)
        {
            throw createAmbiguityException(
                $"Directory '{appDirectory}' contains both Maven and Gradle build files, so the build tool for resource '{resourceName}' is ambiguous. " +
                "Use AddJavaApp and call WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask to choose one explicitly.");
        }
 
        return (hasMaven, hasGradle) switch
        {
            (true, false) => JavaBuildTool.Maven,
            (false, true) => JavaBuildTool.Gradle,
            _ => null
        };
    }
 
    /// <summary>
    /// Resolves the wrapper selected for a resource on the requested execution platform.
    /// </summary>
    /// <remarks>
    /// The application's own directory wins, then the search walks up to the build root. A Gradle
    /// multi-project build has exactly one <c>gradlew</c>, next to the <c>settings.gradle</c> that
    /// declares the subprojects, and Maven multi-module repositories keep <c>mvnw</c> next to the
    /// aggregator POM — so a resource pointed at a module would otherwise never find a wrapper.
    /// <para>
    /// An ancestor only qualifies when it also holds that tool's build-root marker, which keeps an
    /// unrelated wrapper somewhere higher up the filesystem from being picked. The walk stops after
    /// the directory holding <c>.git</c> so a submodule or nested checkout uses its own wrapper
    /// rather than the outer repository's.
    /// </para>
    /// </remarks>
    internal static string ResolveWrapperPath(JavaAppResource resource, JavaBuildTool tool, bool isWindows)
    {
        if (resource.TryGetLastAnnotation<WrapperAnnotation>(out var wrapper))
        {
            return wrapper.WrapperPath;
        }
 
        var wrapperName = GetDefaultWrapperName(tool, isWindows);
        var appDirectory = resource.WorkingDirectory;
 
        return PathNormalizer.NormalizePathForCurrentPlatform(
            FindWrapperInBuildRoot(appDirectory, wrapperName, tool) ?? Path.Combine(appDirectory, wrapperName));
    }
 
    /// <inheritdoc cref="ResolveWrapperPath" />
    private static string? FindWrapperInBuildRoot(string appDirectory, string wrapperName, JavaBuildTool tool)
    {
        for (var directory = SafeDirectoryInfo(appDirectory); directory is not null; directory = directory.Parent)
        {
            var candidate = Path.Combine(directory.FullName, wrapperName);
            var isApplicationDirectory = directory.FullName == appDirectory;
 
            if (File.Exists(candidate)
                && (isApplicationDirectory
                    || (IsBuildRoot(directory.FullName, tool)
                        && !IsWorldWritable(directory)
                        && !IsWorldWritable(new FileInfo(candidate)))))
            {
                return candidate;
            }
 
            // A worktree or submodule records .git as a file rather than a directory, so both count.
            var gitPath = Path.Combine(directory.FullName, ".git");
            if (Directory.Exists(gitPath) || File.Exists(gitPath))
            {
                return null;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Returns whether a directory is the root of a build for <paramref name="tool"/>.
    /// </summary>
    private static bool IsBuildRoot(string directory, JavaBuildTool tool) => tool switch
    {
        // Gradle requires a settings file at the root of a multi-project build; that is the directory
        // the wrapper is generated into. https://docs.gradle.org/current/userguide/multi_project_builds.html
        JavaBuildTool.Gradle => File.Exists(Path.Combine(directory, "settings.gradle"))
                                || File.Exists(Path.Combine(directory, "settings.gradle.kts")),
        // A Maven aggregator is itself a project, so its POM is the marker.
        // https://maven.apache.org/guides/introduction/introduction-to-the-pom.html
        JavaBuildTool.Maven => File.Exists(Path.Combine(directory, "pom.xml")),
        _ => false
    };
 
    /// <summary>
    /// Returns whether any user on the machine can write to <paramref name="entry"/>.
    /// </summary>
    /// <remarks>
    /// The application directory is named in the AppHost, so running the wrapper beside it is the
    /// developer's own instruction. Ancestors are inferred instead, and the walk continues past the
    /// application when no <c>.git</c> marks a checkout boundary - so on a shared machine an
    /// application under a world-writable directory such as <c>/tmp</c> could otherwise pick up a
    /// <c>mvnw</c> another user planted alongside a <c>pom.xml</c>, and execute it with the
    /// developer's privileges before anything is built.
    /// <para>
    /// Applied to the wrapper file as well as its directory, because rewriting a file in place needs
    /// write permission on the file rather than on the directory holding it.
    /// </para>
    /// <para>
    /// Group-writable is deliberately not rejected: distributions that enable user private groups
    /// pair a umask of 002 with a group per user, so an ordinary checkout is mode 775 and rejecting
    /// it would break wrapper resolution for a large share of Linux users. Telling a private group
    /// from a shared one needs the owner and group membership, which .NET does not expose portably.
    /// </para>
    /// <para>
    /// Only applied to inferred ancestors, and only where the mode is meaningful: Windows uses ACLs
    /// that <see cref="UnixFileMode"/> does not describe, and .NET reports
    /// <see cref="UnixFileMode.None"/> there.
    /// </para>
    /// </remarks>
    private static bool IsWorldWritable(FileSystemInfo entry)
    {
        if (OperatingSystem.IsWindows())
        {
            return false;
        }
 
        try
        {
            return entry.UnixFileMode.HasFlag(UnixFileMode.OtherWrite);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // The mode could not be read, so it cannot be shown to be safe. Treating it as
            // world-writable falls back to the wrapper beside the application, which is the same
            // outcome as finding no ancestor wrapper at all.
            return true;
        }
    }
 
    /// <summary>
    /// Returns the directory, or <see langword="null"/> when the path cannot be interpreted.
    /// </summary>
    /// <remarks>
    /// Wrapper resolution runs while the AppHost is still being authored, so the directory may not
    /// exist yet and may be a value the developer has not finished typing.
    /// </remarks>
    private static DirectoryInfo? SafeDirectoryInfo(string path)
    {
        try
        {
            return new DirectoryInfo(path);
        }
        catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
        {
            return null;
        }
    }
 
    /// <summary>
    /// Returns the conventional wrapper name for a build tool on the requested execution platform.
    /// </summary>
    internal static string GetDefaultWrapperName(JavaBuildTool tool, bool isWindows) => (tool, isWindows) switch
    {
        (JavaBuildTool.Maven, true) => "mvnw.cmd",
        (JavaBuildTool.Maven, false) => "mvnw",
        (JavaBuildTool.Gradle, true) => "gradlew.bat",
        (JavaBuildTool.Gradle, false) => "gradlew",
        _ => throw new ArgumentOutOfRangeException(nameof(tool), tool, null)
    };
}