// 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