Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public static string DotnetHomePath
{
get
{
return CliFolderPathCalculatorCore.GetDotnetHomePath()
return new CliFolderPathCalculatorCore().GetDotnetHomePath()
?? throw new ConfigurationException(
string.Format(
LocalizableStrings.FailedToDetermineUserHomeDirectory,
Expand Down
28 changes: 24 additions & 4 deletions src/Common/CliFolderPathCalculatorCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,31 @@

namespace Microsoft.DotNet.Configurer
{
static class CliFolderPathCalculatorCore
class CliFolderPathCalculatorCore
{
public const string DotnetHomeVariableName = "DOTNET_CLI_HOME";
public const string DotnetProfileDirectoryName = ".dotnet";

public static string? GetDotnetUserProfileFolderPath()
private readonly Func<string, string?> _getEnvironmentVariable;

/// <summary>
/// Creates an instance that reads environment variables from the process environment.
/// </summary>
public CliFolderPathCalculatorCore()
: this(Environment.GetEnvironmentVariable)
{
}

/// <summary>
/// Creates an instance that reads environment variables via the supplied delegate.
/// Use this from MSBuild tasks to route reads through TaskEnvironment.
/// </summary>
public CliFolderPathCalculatorCore(Func<string, string?> getEnvironmentVariable)
{
_getEnvironmentVariable = getEnvironmentVariable ?? throw new ArgumentNullException(nameof(getEnvironmentVariable));
}

public string? GetDotnetUserProfileFolderPath()
{
string? homePath = GetDotnetHomePath();
if (homePath is null)
Expand All @@ -19,9 +38,9 @@ static class CliFolderPathCalculatorCore
return Path.Combine(homePath, DotnetProfileDirectoryName);
}

public static string? GetDotnetHomePath()
public string? GetDotnetHomePath()
{
var home = Environment.GetEnvironmentVariable(DotnetHomeVariableName);
var home = _getEnvironmentVariable(DotnetHomeVariableName);
if (string.IsNullOrEmpty(home))
{
home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
Expand All @@ -33,5 +52,6 @@ static class CliFolderPathCalculatorCore

return home;
}

}
}
2 changes: 1 addition & 1 deletion src/RazorSdk/Tool/ServerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ internal static string GetPidFilePath()
var path = Environment.GetEnvironmentVariable("DOTNET_BUILD_PIDFILE_DIRECTORY");
if (string.IsNullOrEmpty(path))
{
var homePath = CliFolderPathCalculatorCore.GetDotnetHomePath();
var homePath = new CliFolderPathCalculatorCore().GetDotnetHomePath();
if (homePath is null)
{
// Couldn't locate the user profile directory. Bail.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ private sealed class CachedState
};

// First check if requested SDK resolves to a workload SDK pack
string? userProfileDir = CliFolderPathCalculatorCore.GetDotnetUserProfileFolderPath();
string? userProfileDir = new CliFolderPathCalculatorCore().GetDotnetUserProfileFolderPath();
ResolutionResult? workloadResult = null;
if (dotnetRoot is not null && netcoreSdkVersion is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private class CachedState
resolverContext.State = cachedState;
}

string? userProfileDir = CliFolderPathCalculatorCore.GetDotnetUserProfileFolderPath();
string? userProfileDir = new CliFolderPathCalculatorCore().GetDotnetUserProfileFolderPath();
ResolutionResult? result = null;
if (cachedState.DotnetRootPath is not null && cachedState.SdkVersion is not null)
{
Expand Down
26 changes: 26 additions & 0 deletions src/Tasks/Common/TaskEnvironmentDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

// Provides a default TaskEnvironment for single-threaded MSBuild execution.
// When MSBuild supports IMultiThreadableTask, it sets TaskEnvironment directly.
// This fallback ensures tasks work with older MSBuild versions that do not set it.

#if NETFRAMEWORK

using System;

namespace Microsoft.Build.Framework
{
internal static class TaskEnvironmentDefaults
{
/// <summary>
/// Creates a default TaskEnvironment backed by the current process environment.
/// Uses Environment.CurrentDirectory as the project directory, which in single-threaded
/// MSBuild is set to the project directory before task execution.
/// </summary>
internal static TaskEnvironment Create() =>
new TaskEnvironment(new ProcessTaskEnvironmentDriver(Environment.CurrentDirectory));
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using FluentAssertions;
using Microsoft.Build.Framework;
using Xunit;

namespace Microsoft.NET.Build.Tasks.UnitTests
{
public class GivenAGenerateBundleMultiThreading
{
[Fact]
public void ItResolvesOutputDirViaTaskEnvironment()
{
// Create a temp directory to act as a fake project dir (different from CWD).
// We can't fully execute GenerateBundle (needs real app host), but we can verify
// the task resolves paths via TaskEnvironment by triggering execution with
// relative paths that only exist under projectDir.
var projectDir = Path.Combine(Path.GetTempPath(), "bundle-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectDir);
try
{
// Create output dir and a dummy source file under projectDir
var outputRelativePath = "publish";
var outputAbsolutePath = Path.Combine(projectDir, outputRelativePath);
Directory.CreateDirectory(outputAbsolutePath);

var sourceRelativePath = Path.Combine("bin", "test.dll");
var sourceAbsolutePath = Path.Combine(projectDir, sourceRelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(sourceAbsolutePath)!);
File.WriteAllText(sourceAbsolutePath, "not a real dll");

var fileItem = new Microsoft.Build.Utilities.TaskItem(sourceRelativePath);
fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll");

var task = new GenerateBundle
{
AppHostName = "testhost.exe",
OutputDir = outputRelativePath,
FilesToBundle = new ITaskItem[] { fileItem },
IncludeSymbols = false,
IncludeNativeLibraries = false,
IncludeAllContent = false,
TargetFrameworkVersion = "8.0",
RuntimeIdentifier = "win-x64",
ShowDiagnosticOutput = false,
EnableCompressionInSingleFile = false,
};
var mockEngine = new MockBuildEngine();
task.BuildEngine = mockEngine;

// Set TaskEnvironment pointing to projectDir (different from CWD).
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir);

// Execute — will fail because the source file isn't a real app host binary,
// but it should attempt to read from the correct (absolutized) path, not from CWD.
// The Bundler constructor will receive the absolutized OutputDir and the source
// file paths will be absolutized, proving TaskEnvironment is used.
// We expect an IOException or similar from the Bundler, NOT a DirectoryNotFoundException
// (which would happen if OutputDir wasn't absolutized).
try
{
task.Execute();
}
catch (Exception ex)
{
// Expected — Bundler can't process fake files.
// But it should NOT be a DirectoryNotFoundException, which would indicate
// OutputDir wasn't absolutized via TaskEnvironment.
ex.Should().NotBeOfType<System.IO.DirectoryNotFoundException>(
"OutputDir should be absolutized via TaskEnvironment, not used as relative path");
}

// If the task didn't absolutize OutputDir, the Bundler would fail trying to use
// a relative path as the output directory. Since we can't easily assert on the
// internal Bundler behavior, the interface and attribute tests above are the
// primary validation, and this test serves as a smoke test.
}
finally
{
Directory.Delete(projectDir, true);
}
}

[Fact]
public void ItHandlesEmptyOutputDir()
{
var projectDir = Path.Combine(Path.GetTempPath(), "bundle-empty-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectDir);
try
{
var fileItem = new Microsoft.Build.Utilities.TaskItem("test.dll");
fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll");

var task = new GenerateBundle
{
AppHostName = "testhost.exe",
OutputDir = "",
FilesToBundle = new ITaskItem[] { fileItem },
IncludeSymbols = false,
IncludeNativeLibraries = false,
IncludeAllContent = false,
TargetFrameworkVersion = "8.0",
RuntimeIdentifier = "win-x64",
ShowDiagnosticOutput = false,
EnableCompressionInSingleFile = false,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir);

Exception? caught = null;
try { task.Execute(); } catch (Exception ex) { caught = ex; }

// Empty OutputDir should produce an ArgumentException from AbsolutePath validation,
// not a NullReferenceException.
caught.Should().NotBeNull("empty OutputDir should fail during path resolution");
caught.Should().NotBeOfType<NullReferenceException>(
"empty OutputDir should not cause NullReferenceException");
}
finally
{
Directory.Delete(projectDir, true);
}
}

[Fact]
public void ItHandlesEmptyFileToBundleItemSpec()
{
var projectDir = Path.Combine(Path.GetTempPath(), "bundle-emptyfile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectDir);
try
{
var outputRelativePath = "publish";
Directory.CreateDirectory(Path.Combine(projectDir, outputRelativePath));

var fileItem = new Microsoft.Build.Utilities.TaskItem("");
fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll");

var task = new GenerateBundle
{
AppHostName = "testhost.exe",
OutputDir = outputRelativePath,
FilesToBundle = new ITaskItem[] { fileItem },
IncludeSymbols = false,
IncludeNativeLibraries = false,
IncludeAllContent = false,
TargetFrameworkVersion = "8.0",
RuntimeIdentifier = "win-x64",
ShowDiagnosticOutput = false,
EnableCompressionInSingleFile = false,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir);

Exception? caught = null;
try { task.Execute(); } catch (Exception ex) { caught = ex; }

// Empty file ItemSpec should produce a meaningful error, not NullReferenceException.
if (caught != null)
{
caught.Should().NotBeOfType<NullReferenceException>(
"empty FilesToBundle ItemSpec should not cause NullReferenceException");
}
}
finally
{
Directory.Delete(projectDir, true);
}
}

[Fact]
public void ItProducesSameExceptionInSingleProcessAndMultiProcessMode()
{
// GenerateBundle requires a real app host binary, so we can't compare actual outputs.
// Instead, we verify both modes fail at the same point (Bundler processing) with
// the same exception type, proving path resolution is equivalent.
var projectDir = Path.Combine(Path.GetTempPath(), "bundle-sp-mp-" + Guid.NewGuid().ToString("N"));
var otherDir = Path.Combine(Path.GetTempPath(), "bundle-other-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectDir);
Directory.CreateDirectory(otherDir);

var savedCwd = Directory.GetCurrentDirectory();
try
{
// Create output dir and dummy source under projectDir
var outputRelativePath = "publish";
Directory.CreateDirectory(Path.Combine(projectDir, outputRelativePath));

var sourceRelativePath = Path.Combine("bin", "test.dll");
var sourceAbsolutePath = Path.Combine(projectDir, sourceRelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(sourceAbsolutePath)!);
File.WriteAllText(sourceAbsolutePath, "not a real dll");

GenerateBundle CreateTask() => new GenerateBundle
{
AppHostName = "testhost.exe",
OutputDir = outputRelativePath,
FilesToBundle = new ITaskItem[]
{
new Microsoft.Build.Utilities.TaskItem(sourceRelativePath)
{
// TaskItem doesn't have init syntax for metadata, set it below
}
},
IncludeSymbols = false,
IncludeNativeLibraries = false,
IncludeAllContent = false,
TargetFrameworkVersion = "8.0",
RuntimeIdentifier = "win-x64",
ShowDiagnosticOutput = false,
EnableCompressionInSingleFile = false,
RetryCount = 0,
};

// --- Single-process run: CWD == projectDir ---
Exception? singleProcessException = null;
Directory.SetCurrentDirectory(projectDir);
try
{
var task = CreateTask();
task.FilesToBundle[0].SetMetadata(MetadataKeys.RelativePath, "test.dll");
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir);
try { task.Execute(); } catch (Exception ex) { singleProcessException = ex; }
}
finally
{
Directory.SetCurrentDirectory(savedCwd);
}

// --- Multi-process run: CWD != projectDir ---
Exception? multiProcessException = null;
Directory.SetCurrentDirectory(otherDir);
try
{
var task = CreateTask();
task.FilesToBundle[0].SetMetadata(MetadataKeys.RelativePath, "test.dll");
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir);
try { task.Execute(); } catch (Exception ex) { multiProcessException = ex; }
}
finally
{
Directory.SetCurrentDirectory(savedCwd);
}

// Both modes should fail at the same point (Bundler, not path resolution)
if (singleProcessException != null && multiProcessException != null)
{
multiProcessException.GetType().Should().Be(singleProcessException.GetType(),
"both single-process and multi-process modes should fail with the same exception type");
}
else
{
// If one succeeded and the other didn't, that's a path resolution problem
(singleProcessException == null).Should().Be(multiProcessException == null,
"both modes should either succeed or fail — a mismatch indicates path resolution discrepancy");
}
}
finally
{
Directory.SetCurrentDirectory(savedCwd);
if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true);
if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true);
}
}
}
}
Loading