File: RustDockerfileGenerator.cs
Web Access
Project: src\src\Aspire.Hosting.Rust\Aspire.Hosting.Rust.csproj (Aspire.Hosting.Rust)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREDOCKERFILEBUILDER001
 
using System.Collections.ObjectModel;
using System.IO.Hashing;
using System.Text;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Rust;
 
/// <summary>
/// Generates the multi-stage Dockerfile that publishes a <see cref="RustAppResource"/>.
/// </summary>
/// <remarks>
/// The container build is the only build: nothing here compiles the crate on the host.
/// </remarks>
internal static partial class RustDockerfileGenerator
{
    // Fully qualified release-line tags avoid Podman's short-name resolution while still picking up patch
    // and security updates. rustup remains available to install whatever a rust-toolchain.toml pins.
    private const string DefaultBuildImage = "docker.io/library/rust:1.97-alpine3.24";
    private const string DefaultRuntimeImage = "docker.io/library/alpine:3.24";
 
    // Kept outside /app so a .cargo/config.toml arriving with the build context cannot move the binary
    // somewhere COPY --from does not look.
    private const string ContainerTargetDirectory = "/build/target";
    private const string ContainerArtifactDirectory = "/build/bin";
 
    // Without this the local target/ directory, routinely several gigabytes, is uploaded to the daemon and
    // copied into the image by `COPY . .`. Cargo puts one next to every manifest, hence the recursive pattern.
    // See https://docs.docker.com/build/concepts/context/#filename-and-location.
    private const string DefaultRustBuildContextIgnoreContent = """
        # Generated by Aspire. Author <contextRoot>/.dockerignore to take over these rules.
        target
        **/target
        .git
        .gitignore
        .DS_Store
        .env
        .env.*
        .aspire
        aspire-output
        Dockerfile
        Dockerfile.*
        *.Dockerfile
        *.Dockerfile.dockerignore
        .dockerignore
 
        """;
 
    public static async Task WriteAsync(RustAppResource resource, DockerfileBuilderCallbackContext context)
    {
        var logger = context.Services.GetService<ILogger<RustAppResource>>();
 
        // Read from the resource so a WithWorkingDirectory applied after AddRustApp is honoured.
        var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
 
        // A <dockerfile>.dockerignore replaces the context root's .dockerignore rather than merging with it,
        // so an authored one wins outright.
        if (context.Resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation)
            && !File.Exists(Path.Combine(workingDirectory, ".dockerignore")))
        {
            dockerfileBuildAnnotation.BuildContextIgnoreContent ??= DefaultRustBuildContextIgnoreContent;
        }
 
        var options = resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var cargoOptions)
            ? cargoOptions
            : new RustCargoOptionsAnnotation();
 
        ValidateDockerfileValue(options.ManifestPath, "cargo manifest path", resource.Name);
        var containerManifestPath = ValidateManifestPath(options.ManifestPath, workingDirectory, resource.Name);
        var targetCacheId = BuildTargetCacheId(resource.Name, containerManifestPath, workingDirectory);
 
        var metadata = await context.Services.GetRequiredService<ICargoMetadataReader>()
            // Empty environment: the resource's environment applies to the process the container runs, not to
            // this host-side manifest query.
            .ReadAsync(workingDirectory, options.ManifestPath, resource.Name, ReadOnlyDictionary<string, string>.Empty, context.CancellationToken)
            .ConfigureAwait(false);
 
        var target = RustCargoTargetResolver.Resolve(
            metadata,
            options,
            context.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            resource.Name);
 
        // Cargo argument callbacks can replace the flags that normally carry these values, but the resolved
        // target still controls Dockerfile artifact paths and target installation.
        ValidateDockerfileValue(target.Name, "resolved Cargo target executable name", resource.Name);
        ValidateDockerfileValue(target.ProfileDirectory, "resolved Cargo profile directory", resource.Name);
        ValidateDockerfileValue(target.Target, "resolved Cargo target triple directory", resource.Name);
 
        // Read from `resource` rather than context.Resource because the latter is the ContainerResource that
        // PublishAsDockerFile substitutes in, which does not carry the Rust annotations.
        var cargoArgs = await ResolvePublishCargoArgsAsync(resource, context.CancellationToken).ConfigureAwait(false);
 
        if (options.ManifestPath is { } path && containerManifestPath is { } containerPath)
        {
            RewriteManifestPath(cargoArgs, path, containerPath);
        }
 
        ValidateCargoArgumentsDoNotContainCredentials(cargoArgs, resource.Name);
 
        foreach (var cargoArg in cargoArgs)
        {
            ValidateDockerfileValue(cargoArg, "cargo argument", resource.Name);
        }
 
        // Images are used exactly as given and nothing is installed into either: a name is free-form, so an
        // image can be musl or glibc based regardless of what it is called. Pairing images that can run what
        // they build, including against any --target, belongs to whoever overrides them.
        var baseImageAnnotation = ResolveBaseImageAnnotation(resource, context);
        var buildImage = baseImageAnnotation?.BuildImage ?? DefaultBuildImage;
        var runtimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultRuntimeImage;
 
        ValidateDockerfileValue(buildImage, "Dockerfile build image", resource.Name);
        ValidateDockerfileValue(runtimeImage, "Dockerfile runtime image", resource.Name);
 
        var buildStage = context.Builder
            .From(buildImage, "build")
            .WorkDir("/app");
 
        // A cross target's standard library is not present in the base image. This has to run after the source
        // is copied: rustup installs the target into the toolchain selected for the directory, so before the
        // copy it would land in the image default and a rust-toolchain.toml pin would build without it.
        var installTarget = target.Target is { } triple
            ? $"rustup target add {ShellQuote(triple)}{CommandContinuation}"
            : "";
 
        buildStage
            .Copy(".", ".")
            // RUSTUP_HOME cannot be cache mounted: mounts start empty and shadow what they cover, so one over
            // /usr/local/rustup hides the toolchains the image ships. The target directory is safe because the
            // selected binary is copied to ContainerArtifactDirectory while the mount is still live, and that
            // is what COPY --from reads. Lock the target cache because clearing stale candidates, building,
            // and collecting the current artifact must be atomic across concurrent BuildKit builds.
            .RunWithMounts(
                $"{installTarget}{BuildArtifactCommand(
                    target,
                    BuildCargoCommand(cargoArgs),
                    ContainerTargetDirectory,
                    ContainerArtifactDirectory)}",
                "type=cache,target=/usr/local/cargo/registry",
                $"type=cache,id={targetCacheId},target={ContainerTargetDirectory},sharing=locked");
 
        // Add intermediate FROM stages for any container files sources (e.g. FROM frontend AS frontend_stage).
        context.Builder.AddContainerFilesStages(context.Resource, logger);
 
        var runtimeStage = context.Builder.From(runtimeImage);
 
        runtimeStage.Run(CreateAppUserCommand);
 
        runtimeStage
            .WorkDir("/app")
            // Add COPY --from=<source> instructions for each container files source.
            .AddContainerFiles(context.Resource, "/app", logger)
            .CopyFrom("build", $"{ContainerArtifactDirectory}/{target.Name}", $"/app/{target.Name}")
            .User("app")
            .Entrypoint([$"/app/{target.Name}"]);
    }
 
    // Evaluates the same callbacks run mode does — the publish-only defaults come from AddInitialCargoArgs,
    // which sees the same execution context — then adds the container-only arguments.
    private static async Task<List<string>> ResolvePublishCargoArgsAsync(
        RustAppResource resource,
        CancellationToken cancellationToken)
    {
        var args = new List<string>();
 
        foreach (var annotation in resource.Annotations.OfType<RustCargoArgsCallbackAnnotation>())
        {
            await annotation.Callback(new RustCargoArgsCallbackContext(resource, args, cancellationToken)).ConfigureAwait(false);
        }
 
        // Appended last because cargo takes the last occurrence of a flag.
        args.Add("--target-dir");
        args.Add(ContainerTargetDirectory);
 
        return args;
    }
 
    // Matches the two-token form WithCargoManifestPath emits. A --manifest-path passed as a raw string
    // through WithCargoArgs is left alone, in keeping with raw arguments being forwarded verbatim.
    private static void RewriteManifestPath(List<string> cargoArgs, string manifestPath, string containerPath)
    {
        for (var i = 0; i < cargoArgs.Count - 1; i++)
        {
            if (cargoArgs[i] == "--manifest-path" && cargoArgs[i + 1] == manifestPath)
            {
                cargoArgs[i + 1] = containerPath;
            }
        }
    }
 
    // Only the app directory is copied into the image, so the manifest has to sit inside it. Paths are
    // required to be relative because an absolute one can spell that same directory differently to us.
    private static string? ValidateManifestPath(string? manifestPath, string workingDirectory, string resourceName)
    {
        if (manifestPath is null)
        {
            return null;
        }
 
        if (Path.IsPathRooted(manifestPath))
        {
            throw new DistributedApplicationException(
                $"The Rust app '{resourceName}' builds from the absolute path '{manifestPath}'. Publishing needs a path " +
                $"relative to its app directory '{workingDirectory}'.");
        }
 
        var platformManifestPath = OperatingSystem.IsWindows()
            ? manifestPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar)
            : manifestPath;
 
        var filesystemWorkingDirectory = Path.GetFullPath(workingDirectory);
        var filesystemManifest = Path.GetFullPath(platformManifestPath, workingDirectory);
 
        if (!PathNormalizer.TryResolveSymlinks(filesystemWorkingDirectory, out var canonicalWorkingDirectory)
            || !PathNormalizer.TryResolveSymlinks(filesystemManifest, out var canonicalManifest))
        {
            throw new DistributedApplicationException(
                $"The Rust app '{resourceName}' builds from '{manifestPath}', but its symbolic links could not be " +
                "fully resolved. Publishing stops rather than accepting a partially canonicalized path.");
        }
 
        // Resolve aliases first so equivalent roots such as /var and /private/var become lexically related.
        // Then only enumerate entries below the build context to recover the spelling Docker will copy.
        canonicalManifest = ResolveFilesystemCasing(canonicalWorkingDirectory, canonicalManifest);
 
        var relativeManifest = Path.GetRelativePath(canonicalWorkingDirectory, canonicalManifest);
 
        if (Path.IsPathRooted(relativeManifest)
            || relativeManifest == ".."
            || relativeManifest.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
            || relativeManifest.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal))
        {
            throw new DistributedApplicationException(
                $"The Rust app '{resourceName}' builds from '{manifestPath}', which resolves outside its app directory " +
                $"'{workingDirectory}'. Only the app directory is copied into the image.");
        }
 
        // Rebase canonical-equivalent spellings (for example /var and /private/var on macOS) to the path
        // below the build context. A Unix backslash is a legal filename character, while a Windows backslash
        // is a host separator that must become a forward slash for the Linux container.
        return OperatingSystem.IsWindows() ? relativeManifest.Replace('\\', '/') : relativeManifest;
    }
 
    private static string ResolveFilesystemCasing(string workingDirectory, string path)
    {
        var originalPath = path;
        var relativePath = Path.GetRelativePath(workingDirectory, path);
        if (relativePath == ".")
        {
            return workingDirectory;
        }
 
        if (Path.IsPathRooted(relativePath)
            || relativePath == ".."
            || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
            || relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal))
        {
            return path;
        }
 
        var segments = relativePath.Split(
            [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
            StringSplitOptions.RemoveEmptyEntries);
        var current = workingDirectory;
 
        for (var i = 0; i < segments.Length; i++)
        {
            var candidate = Path.Combine(current, segments[i]);
            if (!File.Exists(candidate) && !Directory.Exists(candidate))
            {
                for (; i < segments.Length; i++)
                {
                    current = Path.Combine(current, segments[i]);
                }
 
                return current;
            }
 
            // On a case-insensitive host, File.Exists accepts a spelling that will not exist after Docker
            // copies the context into Linux. Enumerating the parent returns the directory entry's stored
            // casing; on case-sensitive hosts the exact candidate above is the only matching entry.
            string[] entries;
            try
            {
                entries = Directory.GetFileSystemEntries(current);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                throw new DistributedApplicationException(
                    $"The filesystem spelling of '{originalPath}' could not be read from '{current}'.",
                    ex);
            }
 
            // APFS can treat canonically equivalent Unicode names as the same entry while returning the
            // stored normalization form. Normalize only for matching, then keep the enumerated spelling.
            var normalizedSegment = segments[i].Normalize(NormalizationForm.FormC);
            current = entries.FirstOrDefault(entry =>
                    string.Equals(Path.GetFileName(entry), segments[i], StringComparison.Ordinal))
                ?? entries.FirstOrDefault(entry =>
                    string.Equals(
                        Path.GetFileName(entry).Normalize(NormalizationForm.FormC),
                        normalizedSegment,
                        StringComparison.OrdinalIgnoreCase))
                ?? throw new DistributedApplicationException(
                    $"The filesystem spelling of '{originalPath}' could not be matched in '{current}'.");
        }
 
        return current;
    }
 
    private static string BuildCargoCommand(List<string> cargoArgs)
        => string.Join(" ", new[] { "cargo", "build" }.Concat(cargoArgs.Select(ShellQuote)));
 
    private static void ValidateCargoArgumentsDoNotContainCredentials(IReadOnlyList<string> cargoArgs, string resourceName)
    {
        for (var i = 0; i < cargoArgs.Count; i++)
        {
            string? configuration = null;
            if (cargoArgs[i] == "--config" && i + 1 < cargoArgs.Count)
            {
                configuration = cargoArgs[++i];
            }
            else if (cargoArgs[i].StartsWith("--config=", StringComparison.Ordinal))
            {
                configuration = cargoArgs[i]["--config=".Length..];
            }
 
            if (configuration is not null
                && (SensitiveCargoConfigAssignmentPattern().IsMatch(configuration)
                    || CredentialBearingUrlPattern().IsMatch(configuration)))
            {
                throw new DistributedApplicationException(
                    $"The Rust app '{resourceName}' has a Cargo --config argument that may contain credentials. " +
                    "Generated Dockerfiles cannot embed credentials; use a hand-written Dockerfile with a BuildKit secret mount instead.");
            }
        }
    }
 
    // Cargo accepts configuration as either:
    //   --config registries.private.token="secret"
    //   --config=env.PGPASSWORD="secret"
    // Match only credential-named assignment keys so safe settings such as
    // `net.git-fetch-with-cli=true` and `registry.credential-provider=...` remain supported.
    [GeneratedRegex(
        """(?:^|[.{,\s"'])(?:PGPASSWORD|MYSQL_PWD|token|password|passwd|secret|credential|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|connection[_-]?strings?)(?:["']?\s*=)""",
        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex SensitiveCargoConfigAssignmentPattern();
 
    // A URL with user information, in the shape `scheme://userinfo@host/path`, persists
    // credentials even when the configuration key itself has an ordinary name.
    [GeneratedRegex("""://[^\s"']+@""", RegexOptions.CultureInvariant)]
    private static partial Regex CredentialBearingUrlPattern();
 
    private static void ValidateDockerfileValue(string? value, string valueDescription, string resourceName)
    {
        if (value is null)
        {
            return;
        }
 
        foreach (var controlCharacter in value.Where(char.IsControl))
        {
            throw new DistributedApplicationException(
                $"The Rust app '{resourceName}' has a {valueDescription} containing the control character " +
                $"U+{(int)controlCharacter:X4}. Control characters cannot be written to a generated Dockerfile.");
        }
    }
 
    private static string BuildTargetCacheId(string resourceName, string? containerManifestPath, string workingDirectory)
    {
        // A BuildKit cache mount id is global to the daemon, so the identity has to separate this crate from
        // every other one built on the machine. The resource name and manifest path alone do not: unrelated
        // app hosts routinely both contain an `api` resource built from `Cargo.toml`, and sharing one target
        // directory between two source trees that both appear at /app lets cargo accept the other tree's
        // local-library and workspace artifacts as fresh on fingerprint and mtime. Including the crate's
        // canonical location means the generated Dockerfile differs between checkouts, which is the accepted
        // cost of not letting one application's build consume another's artifacts.
        var identity = $"{PathNormalizer.ResolveSymlinks(workingDirectory)}\0{resourceName}\0{containerManifestPath ?? "Cargo.toml"}";
        var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(identity));
 
        // Lowercase hexadecimal contains none of the comma, whitespace, or quote delimiters used by
        // Dockerfile mount options, so the stable resource-scoped id can be emitted without escaping.
        return $"aspire-rust-{hash:x16}";
    }
 
    // A custom runtime image may already ship an `app` account. Creating it again fails on both toolsets, and
    // an unconditional `&&` chain would then fail the build over an account that is already exactly what the
    // image needs, so the account is only created when `id` reports it missing. When it does have to be
    // created, BusyBox and shadow-utils disagree on both command names and flags and the image may ship
    // either, so each step tries one and falls back to the other. `|| true` on the group covers an image that
    // predefines the group but not the user. Ids are left to the tool to allocate because none is free on
    // every image: alpine already uses gid 999 for `ping`, and a taken id fails rather than falling through.
    // An image with no `id` at all reports 127, which is non-zero, so creation is still attempted.
    internal const string CreateAppUserCommand =
        "if ! id -u app > /dev/null 2>&1; then " +
        "(addgroup -S app || groupadd --system app || true) && " +
        "(adduser -S -G app app || useradd --system --gid app --no-create-home app); " +
        "fi";
 
    internal static string BuildArtifactCommand(
        RustCargoTarget target,
        string cargoCommand,
        string targetDirectory,
        string artifactDirectory)
        => $"{BuildClearArtifactCommand(target, targetDirectory)}{CommandContinuation}" +
           $"{cargoCommand}{CommandContinuation}{BuildCollectArtifactCommand(target, targetDirectory, artifactDirectory)}";
 
    // The target directory is cache mounted, so remove every path that could satisfy the collector before
    // cargo runs. Deleting only the final executable preserves dependency and incremental build caches while
    // ensuring the current invocation has to materialize the one artifact the runtime stage will copy.
    private static string BuildClearArtifactCommand(RustCargoTarget target, string targetDirectory)
        => $"for candidate in {BuildArtifactCandidates(target, targetDirectory)}; do if [ -f \"$candidate\" ]; then rm -f \"$candidate\"; fi; done";
 
    // Collects the binary to a fixed path so the runtime stage's COPY --from need not know whether cargo
    // inserted a target-triple directory. `--target` is only one way a triple is selected — `[build] target`
    // in a .cargo/config.toml arrives with the build context, and CARGO_BUILD_TARGET can come from the
    // resource environment — so both layouts are searched:
    //   /build/target/release/api            (no target selected)
    //   /build/target/x86_64-.../release/api (some target selected, by whatever means)
    // Only shell builtins plus the POSIX mkdir, cp, and rm utilities are used because the build image is
    // overridable. In particular, the generated build does not assume jq, grep, sed, or find are installed.
    private static string BuildCollectArtifactCommand(
        RustCargoTarget target,
        string targetDirectory,
        string artifactDirectory)
    {
        var destination = ShellQuote($"{artifactDirectory.TrimEnd('/')}/{target.Name}");
 
        return string.Join(
            CommandContinuation,
            [
                "count=0",
                // An unmatched glob stays literal, so each candidate is tested rather than counted.
                $"for candidate in {BuildArtifactCandidates(target, targetDirectory)}; do if [ -f \"$candidate\" ]; then bin=\"$candidate\"; count=$((count+1)); fi; done",
                // `if` rather than `[ ... ] || { ... }`: && and || share precedence and associate left to right,
                // so a trailing || catches the whole preceding chain and reports this on top of cargo's own error.
                $"if [ \"$count\" = 0 ]; then echo \"no\" {ShellQuote($"{target.Name} under {targetDirectory}")} >&2; exit 1; fi",
                $"if [ \"$count\" != 1 ]; then echo \"found $count\" {ShellQuote($"{target.Name} under {targetDirectory} after cargo build")} >&2; exit 1; fi",
                $"mkdir -p {ShellQuote(artifactDirectory)}",
                $"cp \"$bin\" {destination}"
            ]);
    }
 
    private static string BuildArtifactCandidates(RustCargoTarget target, string targetDirectory)
    {
        // Quoting only the suffix keeps the wildcard live: quoting the whole path would make the `*` literal.
        var suffix = ShellQuote(target.RelativePathWithoutTarget);
        var directory = ShellQuote(targetDirectory.TrimEnd('/'));
 
        return $"{directory}/{suffix} {directory}/*/{suffix}";
    }
 
    // An explicit LF rather than Environment.NewLine: a CR after the backslash stops the shell treating the
    // line as a continuation, and the Dockerfile is written verbatim.
    private const string CommandContinuation = " && \\\n    ";
 
    // Dockerfile RUN uses the shell form (/bin/sh -c), so user-supplied values such as bin target and feature
    // names must be quoted. Already-safe tokens are left bare for readability.
    private static string ShellQuote(string value)
    {
        if (value.Length > 0 && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/' or '=' or ',' or '+' or ':'))
        {
            return value;
        }
 
        // An embedded single quote is emitted by closing the quoted run, escaping it, then reopening:
        // don't => 'don'\''t'.
        return $"'{value.Replace("'", "'\\''")}'";
    }
 
    // WithDockerfileBaseImage may be applied to the Rust resource builder or, inside the PublishAsDockerFile
    // callback, to the substituted container resource.
    private static DockerfileBaseImageAnnotation? ResolveBaseImageAnnotation(RustAppResource resource, DockerfileBuilderCallbackContext context)
        => context.Resource.Annotations.OfType<DockerfileBaseImageAnnotation>().LastOrDefault()
            ?? resource.Annotations.OfType<DockerfileBaseImageAnnotation>().LastOrDefault();
}
 
#pragma warning restore ASPIREDOCKERFILEBUILDER001