// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Formats.Tar;
using System.IO.Compression;
using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Aspire.Cli.Tests.Acquisition;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests;
// EnsureExtractedAndAcquireLayoutAsync_SidecarlessInstall_ExtractsToAspireHomeAndAcquiresLayout
// mutates the process-wide ASPIRE_HOME variable. xUnit runs test classes in parallel by default,
// so join EnvVarMutatingTestCollection to serialize with other suites that read ASPIRE_HOME via
// CliPathHelper.GetDefaultAspireHomeDirectory.
[Collection(EnvVarMutatingTestCollection.Name)]
public class BundleServiceIntegrationTests(ITestOutputHelper outputHelper)
{
/// <summary>
/// Verifies that a fresh extraction with a synthetic payload creates a
/// versioned directory, writes a version marker, and establishes reparse
/// points for managed/ and dcp/.
/// </summary>
[Fact]
public async Task ExtractAsync_FreshExtraction_CreatesVersionedLayoutAndLinks()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var payload = CreateFakeBundlePayload();
var provider = new TestBundlePayloadProvider(payload);
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
var service = CreateService(provider, layoutDiscovery);
var result = await service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result);
// Version marker should exist.
var marker = BundleService.ReadVersionMarker(layoutRoot);
Assert.NotNull(marker);
// versions/ directory should contain exactly one version.
var versionsDir = Path.Combine(layoutRoot, BundleService.VersionsDirectoryName);
Assert.True(Directory.Exists(versionsDir));
var versions = Directory.GetDirectories(versionsDir);
Assert.Single(versions);
// bundle/ should be a reparse point pointing into versions/.
var bundleLink = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
try
{
Assert.True(ReparsePoint.IsReparsePoint(bundleLink), "bundle/ should be a reparse point");
// Verify the managed exe is reachable through the bundle reparse point.
var managedExe = Path.Combine(bundleLink,
BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
Assert.True(File.Exists(managedExe), $"managed exe should exist at {managedExe}");
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
/// <summary>
/// Verifies that when an already-extracted layout is up to date, extraction
/// is skipped and returns <see cref="BundleExtractResult.AlreadyUpToDate"/>.
/// </summary>
[Fact]
public async Task ExtractAsync_AlreadyUpToDate_SkipsExtraction()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var payload = CreateFakeBundlePayload();
var provider = new TestBundlePayloadProvider(payload);
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
var service = CreateService(provider, layoutDiscovery);
// First extraction.
var result1 = await service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result1);
try
{
// Second extraction without force — should be up to date.
var result2 = await service.ExtractAsync(layoutRoot, force: false);
Assert.Equal(BundleExtractResult.AlreadyUpToDate, result2);
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
/// <summary>
/// Verifies that upgrading from v1 to v2 flips the reparse points to the
/// new versioned directory and cleans up the old one.
/// </summary>
[Fact]
public async Task ExtractAsync_Upgrade_FlipsLinksAndCleansUpOldVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
// Create fake CLI binaries with different content to produce different version IDs.
var v1BinaryPath = CreateFakeCliBinary(Path.Combine(layoutRoot, ".fake-bins"), "aspire-v1", "cli-v1");
var v2BinaryPath = CreateFakeCliBinary(Path.Combine(layoutRoot, ".fake-bins"), "aspire-v2", "cli-v2-longer");
// Ensure different timestamps.
File.SetLastWriteTimeUtc(v2BinaryPath, File.GetLastWriteTimeUtc(v1BinaryPath).AddHours(1));
// Install v1.
var v1Payload = CreateFakeBundlePayload("v1-content");
var v1Provider = new TestBundlePayloadProvider(v1Payload);
var v1Service = CreateService(v1Provider, layoutDiscovery, v1BinaryPath);
var result1 = await v1Service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result1);
// Capture the v1 version directory.
var versionsDir = Path.Combine(layoutRoot, BundleService.VersionsDirectoryName);
var v1Dirs = Directory.GetDirectories(versionsDir);
Assert.Single(v1Dirs);
var v1VersionDir = v1Dirs[0];
try
{
// Install v2 with a different payload and different binary fingerprint.
var v2Payload = CreateFakeBundlePayload("v2-content");
var v2Provider = new TestBundlePayloadProvider(v2Payload);
var v2Service = CreateService(v2Provider, layoutDiscovery, v2BinaryPath);
var result2 = await v2Service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result2);
// Verify we now have a new version directory (old one cleaned up).
var v2Dirs = Directory.GetDirectories(versionsDir);
Assert.Single(v2Dirs);
Assert.NotEqual(v1VersionDir, v2Dirs[0]);
// The managed exe should be reachable and contain v2 content.
var managedExe = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName,
BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
var content = File.ReadAllText(managedExe);
Assert.Contains("v2-content", content);
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
/// <summary>
/// Verifies that stale version directories are cleaned up after extraction.
/// </summary>
[Fact]
public async Task ExtractAsync_CleansUpStaleVersionDirectories()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var versionsDir = Path.Combine(layoutRoot, BundleService.VersionsDirectoryName);
Directory.CreateDirectory(versionsDir);
// Pre-populate with stale directories.
Directory.CreateDirectory(Path.Combine(versionsDir, "stale-aaaaaaaaaaaaaaaa"));
Directory.CreateDirectory(Path.Combine(versionsDir, $"stale{BundleService.TempSuffixPrefix}deadbeef"));
Directory.CreateDirectory(Path.Combine(versionsDir, $"stale{BundleService.BadSuffixPrefix}99999"));
var payload = CreateFakeBundlePayload();
var provider = new TestBundlePayloadProvider(payload);
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
var service = CreateService(provider, layoutDiscovery);
var result = await service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result);
try
{
// Only the active version should remain.
var remaining = Directory.GetDirectories(versionsDir);
Assert.Single(remaining);
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
[Fact]
public async Task ExtractAsync_PayloadWithoutDcpExecutableFailsVerification()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var payload = CreateFakeBundlePayload(includeDcpExecutable: false);
var service = CreateService(
new TestBundlePayloadProvider(payload),
new TestLayoutDiscovery(layoutRoot));
var result = await service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.ExtractionFailed, result);
Assert.False(Directory.Exists(Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName)));
}
[Fact]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Windows file sharing semantics are required.")]
public async Task MoveDirectoryWithRetryAsync_FileTemporarilyLocked_RetriesUntilReleased()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sourcePath = workspace.CreateDirectory("source").FullName;
var destinationPath = Path.Combine(workspace.WorkspaceRoot.FullName, "destination");
var lockedFilePath = Path.Combine(sourcePath, "locked.dll");
File.WriteAllText(lockedFilePath, "locked");
var service = CreateService(
new TestBundlePayloadProvider(CreateFakeBundlePayload()),
new TestLayoutDiscovery(workspace.WorkspaceRoot.FullName));
using var lockedFile = new FileStream(lockedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var moveTask = service.MoveDirectoryWithRetryAsync(
sourcePath,
destinationPath,
TestContext.Current.CancellationToken);
Assert.False(moveTask.IsCompleted);
lockedFile.Dispose();
await moveTask;
Assert.False(Directory.Exists(sourcePath));
Assert.True(Directory.Exists(destinationPath));
}
[Fact]
public async Task EnsureExtractedAndAcquireLayoutAsync_ReturnsVersionRootedLayoutAndSkipsLeasedCleanup()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
var binDir = Path.Combine(layoutRoot, ".fake-bins");
var v1BinaryPath = CreateFakeCliBinary(binDir, "aspire-v1", "cli-v1");
var v2BinaryPath = CreateFakeCliBinary(binDir, "aspire-v2", "cli-v2-longer");
File.SetLastWriteTimeUtc(v2BinaryPath, File.GetLastWriteTimeUtc(v1BinaryPath).AddHours(1));
// Drop a script sidecar so EnsureExtractedAndAcquireLayoutAsync resolves the
// process-driven extract directory to the parent of the binary directory
// (layoutRoot) rather than the default Aspire home.
File.WriteAllText(Path.Combine(binDir, ".aspire-install.json"), "{\"source\":\"script\"}");
var v1Service = CreateService(new TestBundlePayloadProvider(CreateFakeBundlePayload("v1")), layoutDiscovery, v1BinaryPath);
var result1 = await v1Service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result1);
var versionsDir = Path.Combine(layoutRoot, BundleService.VersionsDirectoryName);
var v1VersionDir = Assert.Single(Directory.GetDirectories(versionsDir));
try
{
using var layoutLease = await v1Service.EnsureExtractedAndAcquireLayoutAsync("test", "reader");
Assert.NotNull(layoutLease);
Assert.True(layoutLease.HasLease);
var managedPath = layoutLease.Layout.GetManagedPath();
Assert.NotNull(managedPath);
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
Assert.StartsWith(
Path.GetFullPath(v1VersionDir),
Path.GetFullPath(managedPath!),
comparison);
var v2Service = CreateService(new TestBundlePayloadProvider(CreateFakeBundlePayload("v2")), layoutDiscovery, v2BinaryPath);
var result2 = await v2Service.ExtractAsync(layoutRoot, force: true);
Assert.Equal(BundleExtractResult.Extracted, result2);
Assert.True(Directory.Exists(v1VersionDir), "Leased stale version should not be deleted during upgrade cleanup.");
Assert.Equal(2, Directory.GetDirectories(versionsDir).Length);
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
[Fact]
public void TryCleanupStaleVersions_SkipsVersionWithActiveLease()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var versionsDir = Path.Combine(workspace.WorkspaceRoot.FullName, BundleService.VersionsDirectoryName);
var staleVersionDir = Path.Combine(versionsDir, "stale-version");
Directory.CreateDirectory(staleVersionDir);
using var lease = BundleVersionLease.Acquire(staleVersionDir, "test", "cleanup");
BundleService.TryCleanupStaleVersions(versionsDir, activeVersionId: "active-version");
Assert.True(Directory.Exists(staleVersionDir));
}
[Fact]
public void TryCleanupStaleVersions_RemovesOrphanLeaseFilesBeforeDeletingVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var versionsDir = Path.Combine(workspace.WorkspaceRoot.FullName, BundleService.VersionsDirectoryName);
var staleVersionDir = Path.Combine(versionsDir, "stale-version");
var leasesDir = Path.Combine(staleVersionDir, BundleVersionLease.LeasesDirectoryName);
Directory.CreateDirectory(leasesDir);
File.WriteAllText(Path.Combine(leasesDir, "orphan.lease"), "{}");
BundleService.TryCleanupStaleVersions(versionsDir, activeVersionId: "active-version");
Assert.False(Directory.Exists(staleVersionDir));
}
[Fact]
public void BundleVersionLease_AllowsConcurrentReadersForSameVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var versionDir = workspace.CreateDirectory("version").FullName;
using var lease1 = BundleVersionLease.Acquire(versionDir, "test", "reader1");
using var lease2 = BundleVersionLease.Acquire(versionDir, "test", "reader2");
Assert.NotEqual(lease1.LeasePath, lease2.LeasePath);
Assert.True(BundleVersionLease.HasActiveLease(versionDir));
}
[Fact]
public async Task EnsureExtractedAsync_DotnetToolStorePath_ExtractsToRidDirectory()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// RID-specific dotnet-tool layout: the native binary lives in the
// RID-scoped directory inside the tool store. The sidecar declares
// source=dotnet-tool so extraction stays at that same RID directory
// (binaryDir) rather than falling back to Aspire home.
var ridDir = Path.Combine(
workspace.WorkspaceRoot.FullName,
".dotnet",
"tools",
".store",
"aspire.cli",
"9.4.0",
"aspire.cli.linux-x64",
"9.4.0",
"tools",
"net10.0",
"linux-x64");
var processPath = CreateFakeCliBinary(
ridDir,
BundleDiscovery.GetExecutableFileName("aspire"),
"native-aot-cli");
File.WriteAllText(Path.Combine(ridDir, ".aspire-install.json"), "{\"source\":\"dotnet-tool\"}");
var payload = CreateFakeBundlePayload();
var provider = new TestBundlePayloadProvider(payload);
var layoutDiscovery = new TestLayoutDiscovery(ridDir);
var service = CreateService(provider, layoutDiscovery, processPath);
await service.EnsureExtractedAsync();
var bundleLink = Path.Combine(ridDir, BundleDiscovery.BundleDirectoryName);
try
{
Assert.True(ReparsePoint.IsReparsePoint(bundleLink), "bundle/ should be a reparse point under the tool RID directory");
var managedExe = Path.Combine(bundleLink,
BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
Assert.True(File.Exists(managedExe), $"managed exe should exist at {managedExe}");
}
finally
{
CleanupReparsePoints(ridDir);
}
}
[Fact]
public async Task EnsureExtractedAndAcquireLayoutAsync_SidecarlessInstall_ExtractsToAspireHomeAndAcquiresLayout()
{
// End-to-end coverage for the sidecar-less install scenario fixed by this PR:
// 1. ComputeDefaultExtractDir routes a no-sidecar CLI to $ASPIRE_HOME instead of
// Path.GetDirectoryName(binaryDir) so binaries in read-only locations (e.g.
// Nix stores) can still extract their bundle.
// 2. The real LayoutDiscovery's Aspire-home probe finds the freshly extracted
// layout even though the CLI binary is outside the bundle hierarchy.
// Individual pieces are covered by ComputeDefaultExtractDir tests and
// LayoutDiscovery_FallsBackToAspireHomeWhenLayoutIsNotRelativeToCli; this test
// locks in the combined behavior end-to-end through EnsureExtractedAndAcquireLayoutAsync.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// CLI lives in a directory unrelated to any layout, with no .aspire-install.json
// sidecar. This simulates an installation in an arbitrary location such as a
// package-manager-controlled, read-only prefix.
var binaryDir = Path.Combine(workspace.WorkspaceRoot.FullName, "opt", "aspire-readonly", "bin");
var processPath = CreateFakeCliBinary(
binaryDir,
BundleDiscovery.GetExecutableFileName("aspire"),
"sidecarless-cli");
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "home", ".aspire");
Directory.CreateDirectory(aspireHome);
var payload = CreateFakeBundlePayload();
var provider = new TestBundlePayloadProvider(payload);
// Use the real LayoutDiscovery (not TestLayoutDiscovery) so the post-extract
// probe must actually find the freshly extracted bundle via the Aspire-home
// fallback. Relative-to-CLI discovery must fail (binary lives outside any
// layout) for the home probe to be exercised.
var layoutDiscovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment())
{
ProcessPathOverride = processPath
};
var service = CreateService(provider, layoutDiscovery, processPath);
var originalAspireHome = Environment.GetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable);
try
{
Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, aspireHome);
using var layoutLease = await service.EnsureExtractedAndAcquireLayoutAsync("test", "sidecarless-flow");
Assert.NotNull(layoutLease);
Assert.True(layoutLease!.HasLease);
// Bundle should have been extracted under $ASPIRE_HOME (not next to the CLI binary).
var bundleLink = Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName);
Assert.True(ReparsePoint.IsReparsePoint(bundleLink),
$"bundle/ should be a reparse point under $ASPIRE_HOME ({aspireHome})");
var managedExe = Path.Combine(bundleLink,
BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
Assert.True(File.Exists(managedExe), $"managed exe should exist at {managedExe}");
// No stray extraction should have leaked next to the CLI binary itself.
Assert.False(
Directory.Exists(Path.Combine(binaryDir, BundleDiscovery.BundleDirectoryName)),
"bundle/ must not be created next to the sidecar-less CLI binary.");
Assert.False(
Directory.Exists(Path.Combine(Path.GetDirectoryName(binaryDir)!, BundleDiscovery.BundleDirectoryName)),
"bundle/ must not be created in the parent of the sidecar-less binary directory.");
// The resolved layout must point into the active version directory under $ASPIRE_HOME,
// proving the post-extract discovery succeeded via the Aspire-home probe rather than a
// cached lookup.
var versionsDir = Path.Combine(aspireHome, BundleService.VersionsDirectoryName);
var activeVersionDir = Assert.Single(Directory.GetDirectories(versionsDir));
var managedPath = layoutLease.Layout.GetManagedPath();
Assert.NotNull(managedPath);
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
Assert.StartsWith(
Path.GetFullPath(activeVersionDir),
Path.GetFullPath(managedPath!),
comparison);
}
finally
{
Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, originalAspireHome);
CleanupReparsePoints(aspireHome);
}
}
/// <summary>
/// Verifies that the static <see cref="BundleService.ExtractPayloadAsync(Stream, string, IEnvironment, CancellationToken)"/>
/// overload correctly extracts a tar.gz stream with strip-components=1 behavior.
/// </summary>
[Fact]
public async Task ExtractPayloadAsync_Static_ExtractsTarGzWithStripComponents()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var dest = workspace.WorkspaceRoot.FullName;
var payload = CreateFakeBundlePayload("test-content");
using var stream = new MemoryStream(payload);
await BundleService.ExtractPayloadAsync(stream, dest, new TestEnvironment(), CancellationToken.None);
// Verify strip-components=1 removed the wrapper directory.
var managedExe = Path.Combine(dest, BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
Assert.True(File.Exists(managedExe));
var content = File.ReadAllText(managedExe);
Assert.Contains("test-content", content);
Assert.True(Directory.Exists(Path.Combine(dest, BundleDiscovery.DcpDirectoryName)));
}
/// <summary>
/// On Windows, verifies that upgrading replaces existing reparse points with
/// new targets pointing at the updated versioned directory.
/// </summary>
[Fact]
public async Task ExtractAsync_ReplacesReparsePointsOnUpgrade()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layoutRoot = workspace.WorkspaceRoot.FullName;
var layoutDiscovery = new TestLayoutDiscovery(layoutRoot);
// Create fake CLI binaries with different fingerprints.
var v1BinaryPath = CreateFakeCliBinary(Path.Combine(layoutRoot, ".fake-bins"), "aspire-v1", "cli-v1");
var v2BinaryPath = CreateFakeCliBinary(Path.Combine(layoutRoot, ".fake-bins"), "aspire-v2", "cli-v2-longer");
File.SetLastWriteTimeUtc(v2BinaryPath, File.GetLastWriteTimeUtc(v1BinaryPath).AddHours(1));
// Install v1.
var v1Payload = CreateFakeBundlePayload("v1");
var v1Service = CreateService(new TestBundlePayloadProvider(v1Payload), layoutDiscovery, v1BinaryPath);
await v1Service.ExtractAsync(layoutRoot, force: true);
var bundleLink = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
var v1Target = ReparsePoint.GetTarget(bundleLink);
Assert.NotNull(v1Target);
try
{
// Install v2.
var v2Payload = CreateFakeBundlePayload("v2");
var v2Service = CreateService(new TestBundlePayloadProvider(v2Payload), layoutDiscovery, v2BinaryPath);
await v2Service.ExtractAsync(layoutRoot, force: true);
var v2Target = ReparsePoint.GetTarget(bundleLink);
Assert.NotNull(v2Target);
Assert.NotEqual(v1Target, v2Target);
}
finally
{
CleanupReparsePoints(layoutRoot);
}
}
/// <summary>
/// Creates a tar.gz byte array containing a fake bundle layout with the
/// required wrapper directory for strip-components=1 extraction.
/// </summary>
internal static byte[] CreateFakeBundlePayload(string contentMarker = "fake-bundle", bool includeDcpExecutable = true)
{
using var ms = new MemoryStream();
using (var gzip = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true))
using (var tar = new TarWriter(gzip, leaveOpen: true))
{
// Top-level wrapper directory (stripped during extraction).
tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, "aspire-payload/"));
// managed/ directory and executable.
tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.ManagedDirectoryName}/"));
var exeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName);
var managedEntry = new PaxTarEntry(TarEntryType.RegularFile, $"aspire-payload/{BundleDiscovery.ManagedDirectoryName}/{exeName}")
{
DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"#!/bin/sh\necho {contentMarker}\n"))
};
tar.WriteEntry(managedEntry);
// dcp/ directory and platform executable.
tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.DcpDirectoryName}/"));
if (includeDcpExecutable)
{
var dcpEntry = new PaxTarEntry(
TarEntryType.RegularFile,
$"aspire-payload/{BundleDiscovery.DcpDirectoryName}/{BundleDiscovery.GetDcpExecutableName()}")
{
DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"dcp-{contentMarker}\n"))
};
tar.WriteEntry(dcpEntry);
}
}
return ms.ToArray();
}
/// <summary>
/// Removes reparse points created during tests to prevent
/// <see cref="TemporaryWorkspace.Dispose"/> from following them
/// and deleting target contents.
/// </summary>
private static void CleanupReparsePoints(string layoutRoot)
{
foreach (var dir in BundleService.s_linkedLayoutDirectories)
{
var linkPath = Path.Combine(layoutRoot, dir);
ReparsePoint.RemoveIfExists(linkPath);
}
}
private static BundleService CreateService(TestBundlePayloadProvider provider, ILayoutDiscovery layoutDiscovery, string? processPathOverride = null)
{
return new BundleService(provider, layoutDiscovery, new TestEnvironment(), NullLogger<BundleService>.Instance)
{
ProcessPathOverride = processPathOverride
};
}
/// <summary>
/// Creates a fake CLI binary file with the given content and returns its path.
/// Different content produces different version fingerprints.
/// </summary>
private static string CreateFakeCliBinary(string directory, string name, string content)
{
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, name);
File.WriteAllText(path, content);
return path;
}
/// <summary>
/// A layout discovery implementation that discovers from a specific root path,
/// checking for bundle/managed/ and bundle/dcp/ subdirectories.
/// </summary>
private sealed class TestLayoutDiscovery(string layoutRoot) : ILayoutDiscovery
{
public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null)
{
var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
var managedDir = Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName);
var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName);
var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName);
var managedExe = Path.Combine(managedDir, managedExeName);
var dcpExe = BundleDiscovery.GetDcpExecutablePath(dcpDir);
if (!Directory.Exists(managedDir) || !File.Exists(managedExe) || !Directory.Exists(dcpDir) || !File.Exists(dcpExe))
{
return null;
}
return new LayoutConfiguration
{
LayoutPath = layoutRoot,
Components = new LayoutComponents
{
Managed = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName),
Dcp = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName),
}
};
}
public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null)
{
var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
return component switch
{
LayoutComponent.Managed => Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
LayoutComponent.Dcp => Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName),
_ => null,
};
}
public bool IsBundleModeAvailable(string? projectDirectory = null) => true;
}
}