File: AddRustAppPublishTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Rust.Tests\Aspire.Hosting.Rust.Tests.csproj (Aspire.Hosting.Rust.Tests)
// 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 ASPIREEXTENSION001
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
 
namespace Aspire.Hosting.Rust.Tests;
 
public class AddRustAppPublishTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task VerifyPublish_DefaultsToAMuslBuildAndRuntimePair()
    {
        var content = await PublishDockerfileAsync();
 
        Assert.StartsWith("FROM docker.io/library/rust:1.97-alpine3.24 AS build", content);
        Assert.Contains("\nFROM docker.io/library/alpine:3.24\n", content);
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_LeavesAPinnedToolchainToRustupInsideTheImage()
    {
        // A pinned toolchain deliberately does not change the build image. rustup is present in the official
        // image and installs whatever the toolchain file names, so the pin is honoured inside the container
        // without the host having to map channel names onto image tags.
        var content = await PublishDockerfileAsync(configureSource: source =>
            File.WriteAllText(Path.Combine(source, "rust-toolchain.toml"), """
                [toolchain]
                channel = "1.89.0"
                """));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithCargoBinTarget()
    {
        var content = await PublishDockerfileAsync(
            metadata: CargoMetadataFactory.SinglePackage("my-service", extraBins: ["worker"]),
            configureResource: app => app.WithCargoBinTarget("worker"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithCargoExample()
    {
        // Examples land in target/<profile>/examples/, so the COPY --from path gets an extra segment.
        var content = await PublishDockerfileAsync(configureResource: app => app.WithCargoExample("demo"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithCargoProfile()
    {
        // A custom profile writes to target/<profile>/, so the COPY --from path must follow it rather than
        // assuming target/release.
        var content = await PublishDockerfileAsync(configureResource: app => app.WithCargoProfile("dist"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithCargoReleaseBuild()
    {
        var content = await PublishDockerfileAsync(configureResource: app => app.WithCargoReleaseBuild());
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithCargoTarget()
    {
        var content = await PublishDockerfileAsync(configureResource: app => app.WithCargoTarget("aarch64-unknown-linux-musl"));
 
        await Verify(content);
    }
 
    [Theory]
    [InlineData("x86_64-unknown-linux-musl", ContainerTargetPlatform.LinuxAmd64, false, false)]
    [InlineData("aarch64-unknown-linux-musl", ContainerTargetPlatform.LinuxArm64, false, false)]
    [InlineData("x86_64-unknown-linux-gnu", ContainerTargetPlatform.LinuxAmd64, true, true)]
    [InlineData("aarch64-unknown-linux-gnu", ContainerTargetPlatform.LinuxArm64, true, true)]
    [InlineData("arm-unknown-linux-musleabi", ContainerTargetPlatform.LinuxArm, true, false)]
    [InlineData("arm-unknown-linux-musleabihf", ContainerTargetPlatform.LinuxArm, true, false)]
    [InlineData("armv7-unknown-linux-musleabi", ContainerTargetPlatform.LinuxArm, true, false)]
    [InlineData("armv7-unknown-linux-musleabihf", ContainerTargetPlatform.LinuxArm, true, false)]
    [InlineData("armv7-unknown-linux-gnueabi", ContainerTargetPlatform.LinuxArm, true, true)]
    [InlineData("armv7-unknown-linux-gnueabihf", ContainerTargetPlatform.LinuxArm, true, true)]
    [InlineData("i686-unknown-linux-musl", ContainerTargetPlatform.Linux386, true, false)]
    [InlineData("i686-unknown-linux-gnu", ContainerTargetPlatform.Linux386, true, true)]
    public async Task PublishMapsSupportedCargoTargetsToTheContainerPlatform(
        string target,
        ContainerTargetPlatform expectedPlatform,
        bool useCustomBuildImage,
        bool useCustomRuntimeImage)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        var rust = builder.AddRustApp("api", sourceDir.FullName).WithCargoTarget(target);
        if (useCustomBuildImage || useCustomRuntimeImage)
        {
            rust.WithDockerfileBaseImage(
                buildImage: useCustomBuildImage ? "example.invalid/rust-cross-build:latest" : null,
                runtimeImage: useCustomRuntimeImage ? "example.invalid/cross-runtime:latest" : null);
        }
 
        using var app = builder.Build();
        app.Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var logger = app.Services.GetRequiredService<ILogger<AddRustAppPublishTests>>();
        var buildOptions = await container.ProcessContainerBuildOptionsCallbackAsync(
            app.Services,
            logger,
            cancellationToken: TestContext.Current.CancellationToken);
 
        Assert.Equal(expectedPlatform, buildOptions.TargetPlatform);
    }
 
    [Theory]
    [InlineData("x86_64-unknown-linux-gnu")]
    [InlineData("armv7-unknown-linux-gnueabihf")]
    [InlineData("i686-unknown-linux-gnu")]
    public async Task PublishRejectsATargetThatNeedsBothCustomImages(string target)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoTarget(target);
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => app.RunAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"The Rust app 'api' targets '{target}', which is not compatible with the default " +
            "musl build and runtime images. Configure both images in a single " +
            "WithDockerfileBaseImage(buildImage: ..., runtimeImage: ...) call before publishing; later calls replace " +
            "the previous configuration.",
            exception.Message);
    }
 
    [Theory]
    [InlineData("armv7-unknown-linux-musleabihf", ContainerTargetPlatform.LinuxArm)]
    [InlineData("i686-unknown-linux-musl", ContainerTargetPlatform.Linux386)]
    public async Task PublishRejectsA32BitTargetWithoutACustomBuildImage(
        string target,
        ContainerTargetPlatform targetPlatform)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoTarget(target);
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => app.RunAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"The Rust app 'api' targets '{target}', but the default Rust build image does not support container " +
            $"target platform '{targetPlatform}'. Configure buildImage with WithDockerfileBaseImage before publishing.",
            exception.Message);
    }
 
    [Fact]
    public async Task PublishRejectsSplitBaseImageCallsForANonMuslTarget()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName)
            .WithCargoTarget("x86_64-unknown-linux-gnu")
            .WithDockerfileBaseImage(buildImage: "docker.io/library/rust:1.97.1-bookworm")
            .WithDockerfileBaseImage(runtimeImage: "docker.io/library/debian:bookworm-slim");
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => app.RunAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(
            "The Rust app 'api' targets 'x86_64-unknown-linux-gnu', which is not compatible with the default " +
            "musl build and runtime images. Configure both images in a single " +
            "WithDockerfileBaseImage(buildImage: ..., runtimeImage: ...) call before publishing; later calls replace " +
            "the previous configuration.",
            exception.Message);
    }
 
    [Fact]
    public async Task PublishRejectsATargetThatCannotMapToAContainerPlatformEvenWithCustomImages()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName)
            .WithCargoTarget("wasm32-wasip1")
            .WithDockerfileBaseImage(
                buildImage: "example.invalid/custom-rust-build:latest",
                runtimeImage: "example.invalid/custom-runtime:latest");
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => app.RunAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(
            "The Rust app 'api' targets 'wasm32-wasip1', which cannot be mapped to a supported Docker Linux " +
            "container platform. Generated Rust containers support native Linux x86_64, aarch64, 32-bit ARM, " +
            "and 32-bit x86 targets. Use an authored Dockerfile to publish this target.",
            exception.Message);
    }
 
    [Fact]
    public async Task PublishRejectsAContainerPlatformThatConflictsWithTheCargoTarget()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName)
            .WithCargoTarget("aarch64-unknown-linux-musl")
            .PublishAsDockerFile(container => container.WithContainerBuildOptions(
                context => context.TargetPlatform = ContainerTargetPlatform.LinuxAmd64));
 
        using var app = builder.Build();
        app.Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var logger = app.Services.GetRequiredService<ILogger<AddRustAppPublishTests>>();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => container.ProcessContainerBuildOptionsCallbackAsync(
                app.Services,
                logger,
                cancellationToken: TestContext.Current.CancellationToken).AsTask());
 
        Assert.Equal(
            "The Rust app 'api' targets 'aarch64-unknown-linux-musl', which requires container target platform " +
            "'LinuxArm64', but WithContainerBuildOptions selected 'LinuxAmd64'.",
            exception.Message);
    }
 
    [Fact]
    public void PublishingPreservesACallerPipelineStepAnnotation()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var pipelineSteps = new PipelineStepAnnotation(_ => Array.Empty<PipelineStep>());
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName)
            .PublishAsDockerFile(container => container.WithAnnotation(
                pipelineSteps,
                ResourceAnnotationMutationBehavior.Replace));
        builder.Build().Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
 
        Assert.Same(pipelineSteps, Assert.Single(container.Annotations.OfType<PipelineStepAnnotation>()));
    }
 
    [Fact]
    public async Task PublishMakesTheRustImageBuildDependOnEachContainerFilesSource()
    {
        // The generated Dockerfile copies out of each container files source, so those sources have to be
        // built first; otherwise the COPY --from reads an image that does not exist yet.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
 
        var frontend = builder.AddResource(new RustFilesContainer("frontend"))
            .WithImage("frontend-image")
            .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" });
        var assets = builder.AddResource(new RustFilesContainer("assets"))
            .WithImage("assets-image")
            .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/public" });
        var rust = builder.AddRustApp("api", sourceDir.FullName);
        rust.PublishWithContainerFiles(frontend, "/app/static");
        rust.PublishWithContainerFiles(assets, "/app/public");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var rustBuildStep = CreateBuildComputeStep("build-api", rust.Resource);
        var frontendBuildStep = CreateBuildComputeStep("build-frontend", frontend.Resource);
        var assetsBuildStep = CreateBuildComputeStep("build-assets", assets.Resource);
 
        // Run every configuration callback the pipeline would, from the model rather than from the Rust
        // resource: PublishAsDockerFile removes that resource, so this also proves the annotation is still
        // reachable through the container substituted in its place.
        foreach (var annotation in model.Resources.SelectMany(resource => resource.Annotations.OfType<PipelineConfigurationAnnotation>()))
        {
            await annotation.Callback(new PipelineConfigurationContext
            {
                Services = app.Services,
                Model = model,
                Steps = [rustBuildStep, frontendBuildStep, assetsBuildStep]
            });
        }
 
        Assert.Equal(["build-frontend", "build-assets"], rustBuildStep.DependsOnSteps);
        Assert.Equal([], frontendBuildStep.DependsOnSteps);
        Assert.Equal([], assetsBuildStep.DependsOnSteps);
    }
 
    private static PipelineStep CreateBuildComputeStep(string name, IResource resource)
        => new()
        {
            Name = name,
            Action = static _ => Task.CompletedTask,
            Tags = [WellKnownPipelineTags.BuildCompute],
            Resource = resource
        };
 
    #pragma warning restore ASPIREPIPELINES003
 
    [Fact]
    public async Task VerifyPublish_ClearsStaleArtifactsBeforeACustomTargetProfileBuild()
    {
        var content = await PublishDockerfileAsync(
            configureResource: app => app
                .WithCargoExample("demo")
                .WithCargoProfile("dist")
                .WithCargoTarget("aarch64-unknown-linux-musl"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursWithDockerfileBaseImage()
    {
        var content = await PublishDockerfileAsync(
            configureResource: app => app.WithDockerfileBaseImage(buildImage: "rust:1.89-bookworm", runtimeImage: "debian:bookworm-slim"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_ForwardsCargoFeaturesAndRawArgs()
    {
        // Regression: the publish build previously hard-coded `cargo build --release`, dropping every
        // configured cargo argument, so a crate needing --no-default-features published a binary that
        // differed from the one that ran locally.
        var content = await PublishDockerfileAsync(
            configureResource: app => app.WithCargoFeatures("grpc-tonic", "tls-ring").WithCargoArgs("--no-default-features", "--locked"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_ShellQuotesArgumentsWithMetacharacters()
    {
        var content = await PublishDockerfileAsync(
            configureResource: app => app.WithCargoArgs("--config", "build.rustflags=[\"--cfg\", 'has_quote']"));
 
        await Verify(content);
    }
 
    [Theory]
    [InlineData("--config", "registries.private.token=\"private-registry-token\"")]
    [InlineData("--config=env.PGPASSWORD=\"database-password\"", null)]
    [InlineData("--config", "registries.private.index=\"https://user:registry-password@example.invalid/index\"")]
    public async Task PublishRejectsCredentialBearingCargoConfigArguments(string argument, string? value)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        var reporter = new TestPipelineActivityReporter(outputHelper);
        builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        var rust = builder.AddRustApp("api", sourceDir.FullName);
        rust.WithCargoArgs(value is null ? [argument] : [argument, value]);
 
        using var app = builder.Build();
        await app.RunAsync(TestContext.Current.CancellationToken);
 
        Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState);
        Assert.Equal(
            "The Rust app 'api' 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.",
            reporter.CompletionMessage);
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Theory]
    [InlineData("raw-argument", "cargo argument", "U+000A")]
    [InlineData("nul-argument", "cargo argument", "U+0000")]
    [InlineData("feature", "cargo argument", "U+000D")]
    [InlineData("manifest-path", "cargo manifest path", "U+000A")]
    [InlineData("binary-path", "resolved Cargo target executable name", "U+001B")]
    [InlineData("build-image", "Dockerfile build image", "U+000A")]
    [InlineData("runtime-image", "Dockerfile runtime image", "U+000D")]
    [InlineData("resolved-executable", "resolved Cargo target executable name", "U+000A")]
    [InlineData("profile-directory-after-args-replaced", "resolved Cargo profile directory", "U+000D")]
    public async Task PublishRejectsDockerfileValuesContainingControlCharacters(
        string valueKind,
        string valueDescription,
        string codePoint)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        var reporter = new TestPipelineActivityReporter(outputHelper);
        builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
        var metadata = valueKind == "resolved-executable"
            ? CargoMetadataFactory.SinglePackage("my\\u000aservice")
            : CargoMetadataFactory.SinglePackage("my-service");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(metadata));
        var rust = builder.AddRustApp("api", sourceDir.FullName);
 
        _ = valueKind switch
        {
            "raw-argument" => rust.WithCargoArgs("--config\nFROM scratch"),
            "nul-argument" => rust.WithCargoArgs("--config\0FROM scratch"),
            "feature" => rust.WithCargoFeatures("safe\rRUN echo injected"),
            "manifest-path" => rust.WithCargoManifestPath("Cargo.toml\nFROM scratch"),
            "binary-path" => rust.WithCargoBinTarget("api\u001b"),
            "build-image" => rust.WithDockerfileBaseImage(buildImage: "rust:1.97\nFROM scratch"),
            "runtime-image" => rust.WithDockerfileBaseImage(runtimeImage: "alpine:3.24\rRUN echo injected"),
            "resolved-executable" => rust,
            "profile-directory-after-args-replaced" => rust
                .WithCargoProfile("dist\r\nRUN echo injected")
                .WithCargoArgs(context => context.Args.Clear()),
            _ => throw new ArgumentOutOfRangeException(nameof(valueKind))
        };
 
        using var app = builder.Build();
        await app.RunAsync(TestContext.Current.CancellationToken);
 
        Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState);
        Assert.Equal(
            $"The Rust app 'api' has a {valueDescription} containing the control character {codePoint}. " +
            "Control characters cannot be written to a generated Dockerfile.",
            reporter.CompletionMessage);
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Fact]
    public async Task VerifyPublish_SelectsAWorkspacePackage()
    {
        var content = await PublishDockerfileAsync(
            metadata: CargoMetadataFactory.Workspace(
                new CargoPackageSpec("api", ["api"]),
                new CargoPackageSpec("worker", ["worker"])),
            configureResource: app => app.WithCargoPackage("worker"));
 
        await Verify(content);
    }
 
    [Fact]
    public void PublishPrefersAHandWrittenDockerfile()
    {
        // A crate that already has a Dockerfile owns its own container build; generating one would silently
        // shadow it.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch\n");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.AddRustApp("api", sourceDir.FullName);
        builder.Build().Run();
 
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Theory]
    [InlineData("wasm32-wasip1")]
    [InlineData("x86_64-unknown-linux-gnu")]
    public void PublishingLeavesCargoTargetHandlingToAHandWrittenDockerfile(string target)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch\n");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.AddRustApp("api", sourceDir.FullName).WithCargoTarget(target);
        builder.Build().Run();
 
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Fact]
    public void PublishingPreservesACallerConfiguredDockerfile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var customContext = workspace.CreateDirectory("custom");
        var outputDir = workspace.CreateDirectory("output");
        var customDockerfile = Path.Combine(customContext.FullName, "Dockerfile.prod");
 
        File.WriteAllText(customDockerfile, "FROM scratch\n");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.AddRustApp("api", sourceDir.FullName)
            .PublishAsDockerFile(container => container.WithDockerfile(customContext.FullName, "Dockerfile.prod"));
        builder.Build().Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var dockerfile = Assert.Single(container.Annotations.OfType<DockerfileBuildAnnotation>());
 
        Assert.Equal(customContext.FullName, dockerfile.ContextPath);
        Assert.Equal(customDockerfile, dockerfile.DockerfilePath);
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Fact]
    public async Task PublishingPreservesACallerConfiguredDockerfileFactory()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var customContext = workspace.CreateDirectory("custom");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.AddRustApp("api", sourceDir.FullName)
            .PublishAsDockerFile(container => container.WithDockerfileFactory(
                customContext.FullName,
                _ => Task.FromResult("FROM scratch\n")));
        builder.Build().Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var dockerfile = Assert.Single(container.Annotations.OfType<DockerfileBuildAnnotation>());
        var content = await File.ReadAllTextAsync(
            Path.Combine(outputDir.FullName, "api.Dockerfile"),
            TestContext.Current.CancellationToken);
 
        Assert.Equal(customContext.FullName, dockerfile.ContextPath);
        Assert.Equal("FROM scratch\n", content);
    }
 
    [Fact]
    public async Task VerifyPublish_KeepsTheManifestPathRelative()
    {
        // Cargo runs from the app directory inside the image, so a relative manifest path names the same file
        // there as it does on the host and is passed through unchanged.
        var content = await PublishDockerfileAsync(
            configureResource: app => app.WithCargoManifestPath("crates/api/Cargo.toml"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task PublishUsesTheFilesystemCasingForTheManifestPath()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var manifestDirectory = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, "Crates", "API"));
        File.WriteAllText(Path.Combine(manifestDirectory.FullName, "Cargo.toml"), "[package]\nname = \"api\"\n");
 
        var differentlyCasedPath = Path.Combine("crates", "api", "cargo.toml");
        if (!File.Exists(Path.Combine(sourceDir.FullName, differentlyCasedPath)))
        {
            Assert.Skip("The test filesystem is case-sensitive.");
        }
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath(differentlyCasedPath);
        builder.Build().Run();
 
        var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
 
        Assert.Contains("cargo build --manifest-path Crates/API/Cargo.toml", content);
    }
 
    [Fact]
    public async Task PublishUsesTheStoredUnicodeNormalizationForTheManifestPath()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var decomposedDirectoryName = "Café".Normalize(NormalizationForm.FormD);
        var composedDirectoryName = decomposedDirectoryName.Normalize(NormalizationForm.FormC);
        var manifestDirectory = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, decomposedDirectoryName));
 
        File.WriteAllText(Path.Combine(manifestDirectory.FullName, "Cargo.toml"), "[package]\nname = \"api\"\n");
 
        var composedManifestPath = Path.Combine(composedDirectoryName, "Cargo.toml");
        if (!File.Exists(Path.Combine(sourceDir.FullName, composedManifestPath)))
        {
            Assert.Skip("The test filesystem distinguishes Unicode normalization forms.");
        }
 
        var storedDirectoryName = Path.GetFileName(Assert.Single(Directory.EnumerateDirectories(sourceDir.FullName)));
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath(composedManifestPath);
        builder.Build().Run();
 
        var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
        var expectedManifestPath = $"{storedDirectoryName}/Cargo.toml";
 
        Assert.Contains($"cargo build --manifest-path '{expectedManifestPath}'", content);
    }
 
    [Fact]
    [UnsupportedOSPlatform("windows")]
    public async Task PublishDoesNotEnumerateAncestorsAboveTheBuildContext()
    {
        if (OperatingSystem.IsWindows())
        {
            Assert.Skip("Unix directory traversal permission regression test.");
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var restrictedAncestor = workspace.CreateDirectory("restricted");
        var sourceDir = Directory.CreateDirectory(Path.Combine(restrictedAncestor.FullName, "source"));
        var manifestDirectory = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, "Crates", "API"));
        var outputDir = workspace.CreateDirectory("output");
 
        File.WriteAllText(Path.Combine(manifestDirectory.FullName, "Cargo.toml"), "[package]\nname = \"api\"\n");
 
        var originalMode = File.GetUnixFileMode(restrictedAncestor.FullName);
        try
        {
            // Execute permission permits access through a known path, while removing read permission prevents
            // enumerating this ancestor. Resolving a manifest below the build context must not need that listing.
            File.SetUnixFileMode(restrictedAncestor.FullName, UnixFileMode.UserExecute);
 
            try
            {
                Directory.GetFileSystemEntries(restrictedAncestor.FullName);
                Assert.Skip("The test filesystem still permits enumerating an execute-only directory.");
            }
            catch (UnauthorizedAccessException)
            {
            }
 
            using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
            builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
            builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath("Crates/API/Cargo.toml");
            builder.Build().Run();
 
            var content = await File.ReadAllTextAsync(
                Path.Combine(outputDir.FullName, "api.Dockerfile"),
                TestContext.Current.CancellationToken);
 
            Assert.Contains("cargo build --manifest-path Crates/API/Cargo.toml", content);
        }
        finally
        {
            File.SetUnixFileMode(restrictedAncestor.FullName, originalMode);
        }
    }
 
    [Fact]
    public async Task VerifyPublish_RewritesWindowsSeparatorsInTheManifestPath()
    {
        if (!OperatingSystem.IsWindows())
        {
            Assert.Skip("Windows manifest separator regression test.");
        }
 
        // A backslash is an ordinary filename character on Linux rather than a separator, so a manifest path
        // authored on Windows would name a file that does not exist in the image.
        var content = await PublishDockerfileAsync(
            configureResource: app => app.WithCargoManifestPath(@"crates\api\Cargo.toml"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task PublishPreservesBackslashesThatAreFilenameCharactersOnUnix()
    {
        if (OperatingSystem.IsWindows())
        {
            Assert.Skip("Unix filename regression test.");
        }
 
        var content = await PublishDockerfileAsync(
            configureSource: source =>
            {
                var manifestDirectory = Directory.CreateDirectory(Path.Combine(source, @"crates\api"));
                File.WriteAllText(Path.Combine(manifestDirectory.FullName, "Cargo.toml"), "[package]\nname = \"api\"\n");
            },
            configureResource: app => app.WithCargoManifestPath(@"crates\api/Cargo.toml"));
 
        Assert.Contains("""cargo build --manifest-path 'crates\api/Cargo.toml'""", content);
    }
 
    [Fact]
    public async Task VerifyPublish_CanonicalizesMacOSAliasesWhenValidatingTheManifestPath()
    {
        if (!OperatingSystem.IsMacOS())
        {
            Assert.Skip("macOS filesystem alias regression test.");
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var canonicalSourceDir = TestPathNormalizer.ResolveSymlinks(sourceDir.FullName);
 
        if (string.Equals(sourceDir.FullName, canonicalSourceDir, StringComparison.Ordinal))
        {
            Assert.Skip("The test temporary directory does not traverse a macOS filesystem alias.");
        }
 
        // Cargo reports realpath-resolved paths such as /private/var/..., while the app directory can retain
        // the equivalent /var/... spelling. Express the canonical manifest as a relative path from the
        // lexical app directory to reproduce that mismatch without depending on /tmp.
        var canonicalManifest = Path.Combine(canonicalSourceDir, "crates", "api", "Cargo.toml");
        var manifestPath = Path.GetRelativePath(sourceDir.FullName, canonicalManifest);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath(manifestPath);
        builder.Build().Run();
 
        var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
 
        await Verify(content);
    }
 
    [Fact]
    public async Task PublishPreservesManifestCasingAcrossAMacOSFilesystemAlias()
    {
        if (!OperatingSystem.IsMacOS())
        {
            Assert.Skip("macOS filesystem alias regression test.");
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var canonicalSourceDir = TestPathNormalizer.ResolveSymlinks(sourceDir.FullName);
        var manifestDirectory = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, "Crates", "API"));
 
        File.WriteAllText(Path.Combine(manifestDirectory.FullName, "Cargo.toml"), "[package]\nname = \"api\"\n");
 
        if (string.Equals(sourceDir.FullName, canonicalSourceDir, StringComparison.Ordinal))
        {
            Assert.Skip("The test temporary directory does not traverse a macOS filesystem alias.");
        }
 
        var differentlyCasedCanonicalManifest = Path.Combine(canonicalSourceDir, "crates", "api", "cargo.toml");
        if (!File.Exists(differentlyCasedCanonicalManifest))
        {
            Assert.Skip("The test filesystem is case-sensitive.");
        }
 
        var manifestPath = Path.GetRelativePath(sourceDir.FullName, differentlyCasedCanonicalManifest);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath(manifestPath);
        builder.Build().Run();
 
        var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
 
        Assert.Contains("cargo build --manifest-path Crates/API/Cargo.toml", content);
    }
 
    [Fact]
    public void PublishRejectsAnInContextSymlinkThatResolvesOutsideTheBuildContext()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outsideDir = workspace.CreateDirectory("outside");
        var outputDir = workspace.CreateDirectory("output");
        File.WriteAllText(Path.Combine(outsideDir.FullName, "Cargo.toml"), "[package]\nname = \"outside\"\n");
        CreateDirectorySymbolicLinkOrSkip(Path.Combine(sourceDir.FullName, "linked"), outsideDir.FullName);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath("linked/Cargo.toml");
        builder.Build().Run();
 
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Fact]
    public void PublishFailsClosedWhenAManifestTraversesCircularSymlinks()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        var firstLink = Path.Combine(sourceDir.FullName, "linked");
        var secondLink = Path.Combine(sourceDir.FullName, "loop");
        CreateDirectorySymbolicLinkOrSkip(
            firstLink,
            secondLink);
        CreateDirectorySymbolicLinkOrSkip(
            secondLink,
            firstLink);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName).WithCargoManifestPath("linked/Cargo.toml");
        builder.Build().Run();
 
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")));
    }
 
    [Theory]
    [InlineData("../elsewhere/Cargo.toml")]
    [InlineData("crates/../../elsewhere/Cargo.toml")]
    [InlineData("crates/api/../../../../../../Cargo.toml")]
    [InlineData("../appsuffix/Cargo.toml")]
    public async Task PublishFailsWhenTheManifestIsOutsideTheAppDirectory(string manifestPath)
    {
        // Only the app directory is copied into the image, so a manifest above it could never be built there.
        // The .. segments are collapsed before the path is judged, so an escape buried mid-path is caught too.
        // The publish pipeline reports the failure through the host rather than rethrowing, so the observable
        // result is that no Dockerfile is produced.
        var exception = await Record.ExceptionAsync(
            () => PublishDockerfileAsync(configureResource: app => app.WithCargoManifestPath(manifestPath)));
 
        Assert.IsType<FileNotFoundException>(exception);
    }
 
    [Fact]
    public async Task PublishFailsWhenTheManifestPathIsAbsolute()
    {
        // An absolute path is fine when running, but publishing copies only the app directory into the image,
        // and an absolute path can spell that directory differently to the app host.
        var exception = await Record.ExceptionAsync(
            () => PublishDockerfileAsync(
                configureResource: app => app.WithCargoManifestPath(Path.Combine(Path.GetTempPath(), "Cargo.toml"))));
 
        Assert.IsType<FileNotFoundException>(exception);
    }
 
    [Fact]
    public async Task VerifyPublish_AddsLockedWhenTheCrateHasALockFile()
    {
        // A committed lock file is the whole point of --locked: the image must build the dependency versions
        // that were reviewed, not whatever resolves at build time.
        var content = await PublishDockerfileAsync(configureSource: source =>
            File.WriteAllText(Path.Combine(source, "Cargo.lock"), "version = 4\n"));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_HonoursOptingOutOfTheLockedAndReleaseDefaults()
    {
        var content = await PublishDockerfileAsync(
            configureSource: source => File.WriteAllText(Path.Combine(source, "Cargo.lock"), "version = 4\n"),
            configureResource: app => app.WithCargoLocked(false).WithCargoReleaseBuild(false));
 
        await Verify(content);
    }
 
    [Fact]
    public async Task PublishScopesTheTargetCacheToTheCrateDirectory()
    {
        // A BuildKit cache mount id is global to the daemon. Unrelated app hosts commonly both build an `api`
        // resource from `Cargo.toml`, and sharing one Cargo target directory while both source trees appear at
        // /app lets cargo accept the other tree's local-library artifacts as fresh on fingerprint and mtime.
        var first = await PublishDockerfileAsync();
        var second = await PublishDockerfileAsync();
 
        var firstCacheId = ReadTargetCacheId(first);
        var secondCacheId = ReadTargetCacheId(second);
 
        Assert.NotEqual(firstCacheId, secondCacheId);
 
        // The cache identity is the only thing that may differ between checkouts of an equivalent crate.
        Assert.Equal(
            first.Replace(firstCacheId, "aspire-rust-scope", StringComparison.Ordinal),
            second.Replace(secondCacheId, "aspire-rust-scope", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task PublishReusesTheTargetCacheForTheSameCrateDirectory()
    {
        // Scoping the cache must not defeat it: republishing the same crate has to keep hitting the cargo
        // target directory it filled last time.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var firstOutputDir = workspace.CreateDirectory("output-first");
        var secondOutputDir = workspace.CreateDirectory("output-second");
 
        foreach (var outputDir in new[] { firstOutputDir, secondOutputDir })
        {
            using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
            builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
            builder.AddRustApp("api", sourceDir.FullName);
            builder.Build().Run();
        }
 
        var first = await File.ReadAllTextAsync(Path.Combine(firstOutputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
        var second = await File.ReadAllTextAsync(Path.Combine(secondOutputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
 
        Assert.Equal(ReadTargetCacheId(first), ReadTargetCacheId(second));
    }
 
    [Fact]
    public async Task PublishIsolatesTheTargetCacheByResourceName()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName);
        builder.AddRustApp("worker", sourceDir.FullName);
        builder.Build().Run();
 
        var first = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
        var second = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "worker.Dockerfile"), TestContext.Current.CancellationToken);
 
        Assert.NotEqual(ReadTargetCacheId(first), ReadTargetCacheId(second));
    }
 
    [Fact]
    public async Task VerifyPublish_DoesNotRepeatLockedWhenTheResourceAlreadyAskedForIt()
    {
        // Run mode already emitted --locked, and passing it twice makes cargo's own error messages confusing.
        var content = await PublishDockerfileAsync(configureResource: app => app.WithCargoLocked());
 
        await Verify(content);
    }
 
    [Fact]
    public async Task VerifyPublish_EmitsABuildContextIgnoreThatExcludesTargetDirectories()
    {
        // `COPY . .` would otherwise upload the crate's local target/ directory, which is routinely several
        // gigabytes and is rebuilt inside the image regardless.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName);
        builder.Build().Run();
 
        var ignore = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile.dockerignore"), TestContext.Current.CancellationToken);
 
        await Verify(ignore);
    }
 
    [Fact]
    public async Task AnAuthoredDockerignoreTakesOverFromTheDefaults()
    {
        // Docker gives <dockerfile>.dockerignore precedence over the context root's .dockerignore instead of
        // merging them, so emitting the defaults would silently discard the crate's own rules.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        File.WriteAllText(Path.Combine(sourceDir.FullName, ".dockerignore"), "*.md\n");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName);
        builder.Build().Run();
 
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile.dockerignore")));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void PublishingMaterializesTheDockerfileFromTheFinalWorkingDirectory(bool hasDockerfile)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var relocatedDir = workspace.CreateDirectory("relocated");
        var outputDir = workspace.CreateDirectory("output");
 
        if (hasDockerfile)
        {
            File.WriteAllText(Path.Combine(relocatedDir.FullName, "Dockerfile"), "FROM scratch\n");
        }
 
        var reader = new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service"));
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(reader);
        builder.AddRustApp("api", sourceDir.FullName).WithWorkingDirectory(relocatedDir.FullName);
        var initialContainer = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var initialDockerfile = Assert.Single(initialContainer.Annotations.OfType<DockerfileBuildAnnotation>());
        initialDockerfile.BuildContextIgnoreContent = "custom-ignore\n";
        builder.Build().Run();
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        var dockerfile = Assert.Single(container.Annotations.OfType<DockerfileBuildAnnotation>());
 
        Assert.Equal(relocatedDir.FullName, dockerfile.ContextPath);
        Assert.Equal("custom-ignore\n", dockerfile.BuildContextIgnoreContent);
 
        if (hasDockerfile)
        {
            Assert.Equal(Path.Combine(relocatedDir.FullName, "Dockerfile"), dockerfile.DockerfilePath);
            Assert.Equal(0, reader.ReadCount);
        }
        else
        {
            Assert.Equal(relocatedDir.FullName, reader.LastWorkingDirectory);
        }
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task PublishBoundsTheLockFileSearchToAnAppDirectoryWithATrailingSeparator(bool lockFileInsideAppDirectory)
    {
        // Path.GetFullPath keeps a trailing separator while Path.GetDirectoryName drops it, so an app
        // directory spelled "../rust-api/" compared unequal to the directory the manifest resolved to and the
        // search climbed into the repository above it. Only the app directory is copied into the image, so a
        // Cargo.lock found above it would make --locked fail the container build.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Cargo.lock"), "version = 4\n");
 
        if (lockFileInsideAppDirectory)
        {
            File.WriteAllText(Path.Combine(sourceDir.FullName, "Cargo.lock"), "version = 4\n");
        }
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
        builder.Services.AddSingleton<ICargoMetadataReader>(new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
        builder.AddRustApp("api", sourceDir.FullName + Path.DirectorySeparatorChar)
            .WithCargoManifestPath("Cargo.toml");
        builder.Build().Run();
 
        var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
        var expectedCargoCommand = lockFileInsideAppDirectory
            ? "cargo build --manifest-path Cargo.toml --locked --release --target-dir /build/target"
            : "cargo build --manifest-path Cargo.toml --release --target-dir /build/target";
 
        Assert.Equal(expectedCargoCommand, ReadCargoBuildCommand(content));
    }
 
    // Reads the id from the generated target cache mount, which is emitted as:
    //   RUN --mount=type=cache,target=/usr/local/cargo/registry --mount=type=cache,id=aspire-rust-0123456789abcdef,target=/build/target,sharing=locked \
    private static string ReadTargetCacheId(string dockerfile)
    {
        var match = Regex.Match(
            dockerfile,
            @"--mount=type=cache,id=(aspire-rust-[0-9a-f]{16}),target=/build/target,sharing=locked");
 
        Assert.True(match.Success, "The generated Dockerfile does not contain a scoped target cache mount.");
 
        return match.Groups[1].Value;
    }
 
    // Reads the cargo invocation out of the generated RUN, whose lines are joined by " && \" continuations:
    //     for candidate in ...; done && \
    //     cargo build --release --target-dir /build/target && \
    //     count=0 && \
    private static string ReadCargoBuildCommand(string dockerfile)
    {
        var line = Assert.Single(
            dockerfile.Split('\n'),
            static l => l.TrimStart().StartsWith("cargo build", StringComparison.Ordinal));
 
        return line.Trim().TrimEnd('\\').TrimEnd().TrimEnd('&').TrimEnd();
    }
 
    [Fact]
    public async Task PublishCargoArgsCallbackReceivesTheRustResource()
    {
        // Publishing evaluates the cargo argument callbacks on its own path (Dockerfile generation) rather
        // than through the run-mode argument pipeline, so the resource has to arrive there too.
        RustAppResource? configuredResource = null;
        var observed = new List<RustAppResource>();
 
        await PublishDockerfileAsync(configureResource: app =>
        {
            configuredResource = app.Resource;
            return app.WithCargoArgs(context => observed.Add(context.Resource));
        });
 
        Assert.NotNull(configuredResource);
        Assert.NotEmpty(observed);
        Assert.All(observed, resource => Assert.Same(configuredResource, resource));
    }
 
    private async Task<string> PublishDockerfileAsync(
        Action<string>? configureSource = null,
        string? metadata = null,
        Func<IResourceBuilder<RustAppResource>, IResourceBuilder<RustAppResource>>? configureResource = null)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var sourceDir = workspace.CreateDirectory("source");
        var outputDir = workspace.CreateDirectory("output");
 
        configureSource?.Invoke(sourceDir.FullName);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
 
        // Answer cargo metadata from a canned document so these tests exercise Dockerfile generation on
        // machines without a Rust toolchain installed.
        builder.Services.AddSingleton<ICargoMetadataReader>(
            new FakeCargoMetadataReader(metadata ?? CargoMetadataFactory.SinglePackage("my-service")));
 
        var app = builder.AddRustApp("api", sourceDir.FullName);
 
        configureResource?.Invoke(app);
 
        builder.Build().Run();
 
        return await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken);
    }
 
    private static void CreateDirectorySymbolicLinkOrSkip(string linkPath, string targetPath)
    {
        try
        {
            Directory.CreateSymbolicLink(linkPath, targetPath);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
        {
            Assert.Skip($"Symbolic links are unavailable in this test environment: {ex.Message}");
        }
    }
 
    // Minimal container resource that can act as a container files source, so PublishWithContainerFiles can be
    // exercised without depending on a real container integration.
    private sealed class RustFilesContainer(string name) : ContainerResource(name), IResourceWithContainerFiles;
}