Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/Tasks.UnitTests/GetReferencePaths_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,93 @@ public void TestGeneralFrameworkMonikerGoodWithFrameworkInFallbackPaths()
Assert.Equal(".NET Framework 4.1", displayName);
}
}

/// <summary>
/// Test that a relative RootPath resolved via TaskEnvironment produces the same result as an absolute RootPath.
/// </summary>
[Fact]
public void TestRelativeRootPathProducesSameResultAsAbsolute()
{
using (var env = TestEnvironment.Create())
{
string baseDir = env.DefaultTestDirectory.Path;
Comment on lines +336 to +340
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test convention: new tests should inject ITestOutputHelper (store as _output) and create the environment via TestEnvironment.Create(_output) so diagnostic output is captured in CI. Also prefer Shouldly assertions over adding new xUnit Assert calls in modified code.

Copilot uses AI. Check for mistakes.
string relativeDir = "framework-root";
string absoluteDir = Path.Combine(baseDir, relativeDir);
var framework41Directory = env.CreateFolder(Path.Combine(absoluteDir, Path.Combine("MyFramework", "v4.1") + Path.DirectorySeparatorChar));
var redistListDirectory = env.CreateFolder(Path.Combine(framework41Directory.Path, "RedistList"));
env.CreateFile(redistListDirectory, "FrameworkList.xml",
"<FileList Redist='Microsoft-Windows-CLRCoreComp' Name='.NET Framework 4.1'>" +
"<File AssemblyName='System.Xml' Version='2.0.0.0' PublicKeyToken='b03f5f7f11d50a3a' Culture='Neutral' FileVersion='2.0.50727.208' InGAC='true' />" +
"</FileList >");

// Baseline: absolute RootPath
MockEngine absoluteEngine = new MockEngine();
GetReferenceAssemblyPaths absoluteTask = new GetReferenceAssemblyPaths();
absoluteTask.BuildEngine = absoluteEngine;
absoluteTask.TargetFrameworkMoniker = "MyFramework, Version=v4.1";
absoluteTask.RootPath = absoluteDir;
absoluteTask.Execute();

// Test: relative RootPath with TaskEnvironment
MockEngine relativeEngine = new MockEngine();
GetReferenceAssemblyPaths relativeTask = new GetReferenceAssemblyPaths();
relativeTask.BuildEngine = relativeEngine;
relativeTask.TargetFrameworkMoniker = "MyFramework, Version=v4.1";
relativeTask.TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(baseDir);
relativeTask.RootPath = relativeDir;
relativeTask.Execute();

Assert.Equal(absoluteTask.ReferenceAssemblyPaths, relativeTask.ReferenceAssemblyPaths);
Assert.Equal(absoluteTask.TargetFrameworkMonikerDisplayName, relativeTask.TargetFrameworkMonikerDisplayName);
Assert.Equal(0, relativeEngine.Errors);
}
}

/// <summary>
/// Test that a relative path in TargetFrameworkFallbackSearchPaths resolved via TaskEnvironment
/// produces the same result as an absolute fallback path.
/// </summary>
[Fact]
public void TestRelativeFallbackSearchPathProducesSameResultAsAbsolute()
{
using (var env = TestEnvironment.Create())
{
string baseDir = env.DefaultTestDirectory.Path;
Comment on lines +378 to +382
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test convention: prefer using TestEnvironment env = TestEnvironment.Create(_output); (using-declaration, no braces) to align with repo patterns and ensure output capture. New assertions in this test should also use Shouldly rather than adding more xUnit Assert calls.

Copilot uses AI. Check for mistakes.
string relativeDir = "framework-root";
string absoluteDir = Path.Combine(baseDir, relativeDir);
var framework41Directory = env.CreateFolder(Path.Combine(absoluteDir, Path.Combine("MyFramework", "v4.1") + Path.DirectorySeparatorChar));
var redistListDirectory = env.CreateFolder(Path.Combine(framework41Directory.Path, "RedistList"));
env.CreateFile(redistListDirectory, "FrameworkList.xml",
"<FileList Redist='Microsoft-Windows-CLRCoreComp' Name='.NET Framework 4.1'>" +
"<File AssemblyName='System.Xml' Version='2.0.0.0' PublicKeyToken='b03f5f7f11d50a3a' Culture='Neutral' FileVersion='2.0.50727.208' InGAC='true' />" +
"</FileList >");

string nonExistentRoot = Path.Combine(baseDir, "nonexistent");

// Baseline: absolute fallback path
MockEngine absoluteEngine = new MockEngine();
GetReferenceAssemblyPaths absoluteTask = new GetReferenceAssemblyPaths();
absoluteTask.BuildEngine = absoluteEngine;
absoluteTask.TargetFrameworkMoniker = "MyFramework, Version=v4.1";
absoluteTask.RootPath = nonExistentRoot;
absoluteTask.TargetFrameworkFallbackSearchPaths = absoluteDir;
absoluteTask.Execute();

// Test: relative fallback path with TaskEnvironment
MockEngine relativeEngine = new MockEngine();
GetReferenceAssemblyPaths relativeTask = new GetReferenceAssemblyPaths();
relativeTask.BuildEngine = relativeEngine;
relativeTask.TargetFrameworkMoniker = "MyFramework, Version=v4.1";
relativeTask.TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(baseDir);
relativeTask.RootPath = nonExistentRoot;
relativeTask.TargetFrameworkFallbackSearchPaths = relativeDir;
relativeTask.Execute();

Assert.Equal(absoluteTask.ReferenceAssemblyPaths, relativeTask.ReferenceAssemblyPaths);
Assert.Equal(absoluteTask.TargetFrameworkMonikerDisplayName, relativeTask.TargetFrameworkMonikerDisplayName);
Assert.Equal(0, relativeEngine.Errors);
}
}
}
}
#endif
76 changes: 58 additions & 18 deletions src/Tasks/GetReferenceAssemblyPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
using System;
using System.Collections.Generic;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Utilities;
using FrameworkNameVersioning = System.Runtime.Versioning.FrameworkName;

#if FEATURE_GAC
using Microsoft.Build.Shared;
using System.Threading;
using SystemProcessorArchitecture = System.Reflection.ProcessorArchitecture;
#endif

Expand All @@ -19,8 +20,12 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Returns the reference assembly paths to the various frameworks
/// </summary>
public class GetReferenceAssemblyPaths : TaskExtension
[MSBuildMultiThreadableTask]
public class GetReferenceAssemblyPaths : TaskExtension, IMultiThreadableTask
{
/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

#region Data
#if FEATURE_GAC
/// <summary>
Expand All @@ -32,7 +37,23 @@ public class GetReferenceAssemblyPaths : TaskExtension
/// <summary>
/// Cache in a static whether or not we have found the 35sp1sentinel assembly.
/// </summary>
private static bool? s_net35SP1SentinelAssemblyFound;
private static readonly Lazy<bool> s_net35SP1SentinelAssemblyFound = new Lazy<bool>(() =>
{
// get an assemblyname from the string representation of the sentinel assembly name
var sentinelAssemblyName = new AssemblyNameExtension(NET35SP1SentinelAssemblyName);
string path = GlobalAssemblyCache.GetLocation(
sentinelAssemblyName,
SystemProcessorArchitecture.MSIL,
runtimeVersion => "v2.0.50727",
new Version("2.0.57027"),
false,
new FileExists(p => FileUtilities.FileExistsNoThrow(p)),
GlobalAssemblyCache.pathFromFusionName,
GlobalAssemblyCache.gacEnumerator,
false);

return !string.IsNullOrEmpty(path);
}, LazyThreadSafetyMode.PublicationOnly);
#endif

/// <summary>
Expand Down Expand Up @@ -144,6 +165,11 @@ public string TargetFrameworkFallbackSearchPaths
/// </summary>
public override bool Execute()
{
AbsolutePath? absoluteRootPath = !string.IsNullOrEmpty(RootPath)
? TaskEnvironment.GetAbsolutePath(RootPath)
: new AbsolutePath(RootPath, ignoreRootedCheck: true);
IList<AbsolutePath> absoluteFallbackSearchPaths = ResolveAbsoluteFallbackSearchPaths(TargetFrameworkFallbackSearchPaths);

Comment on lines +168 to +172
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolving RootPath/TargetFrameworkFallbackSearchPaths through TaskEnvironment.GetAbsolutePath(...) changes observable behavior when callers pass relative paths: ReferenceAssemblyPaths will now be absolute, whereas ToolLocationHelper.GetPathToReferenceAssemblies previously returned paths rooted in the original (possibly relative) inputs. For built-in tasks, this kind of behavior change should be gated behind a ChangeWave, or otherwise preserve legacy behavior when relative inputs are provided (while still fixing multithreaded correctness).

Copilot uses AI. Check for mistakes.
FrameworkNameVersioning moniker;
FrameworkNameVersioning monikerWithNoProfile = null;

Expand All @@ -169,16 +195,6 @@ public override bool Execute()
if (!BypassFrameworkInstallChecks && moniker.Identifier.Equals(".NETFramework", StringComparison.OrdinalIgnoreCase) &&
moniker.Version.Major < 4)
{
// We have not got a value for whether or not the 35 sentinel assembly has been found
if (!s_net35SP1SentinelAssemblyFound.HasValue)
{
// get an assemblyname from the string representation of the sentinel assembly name
var sentinelAssemblyName = new AssemblyNameExtension(NET35SP1SentinelAssemblyName);

string path = GlobalAssemblyCache.GetLocation(sentinelAssemblyName, SystemProcessorArchitecture.MSIL, runtimeVersion => "v2.0.50727", new Version("2.0.57027"), false, new FileExists(p => FileUtilities.FileExistsNoThrow(p)), GlobalAssemblyCache.pathFromFusionName, GlobalAssemblyCache.gacEnumerator, false);
s_net35SP1SentinelAssemblyFound = !String.IsNullOrEmpty(path);
}

// We did not find the SP1 sentinel assembly in the GAC. Therefore we must assume that SP1 isn't installed
if (!s_net35SP1SentinelAssemblyFound.Value)
{
Expand All @@ -195,7 +211,7 @@ public override bool Execute()

try
{
_tfmPaths = GetPaths(RootPath, TargetFrameworkFallbackSearchPaths, moniker);
_tfmPaths = GetPaths(absoluteRootPath, absoluteFallbackSearchPaths, moniker);

if (_tfmPaths?.Count > 0)
{
Expand All @@ -206,7 +222,7 @@ public override bool Execute()
// There is no point in generating the full framework paths if profile path could not be found.
if (targetingProfile && _tfmPaths != null)
{
_tfmPathsNoProfile = GetPaths(RootPath, TargetFrameworkFallbackSearchPaths, monikerWithNoProfile);
_tfmPathsNoProfile = GetPaths(absoluteRootPath, absoluteFallbackSearchPaths, monikerWithNoProfile);
}

// The path with out the profile is just the reference assembly paths.
Expand Down Expand Up @@ -236,14 +252,18 @@ public override bool Execute()
/// <summary>
/// Generate the set of chained reference assembly paths
/// </summary>
private IList<String> GetPaths(string rootPath, string targetFrameworkFallbackSearchPaths, FrameworkNameVersioning frameworkmoniker)
private IList<String> GetPaths(AbsolutePath? rootPath, IList<AbsolutePath> fallbackSearchPaths, FrameworkNameVersioning frameworkmoniker)
{
string fallbackSearchPathsJoined = fallbackSearchPaths.Count > 0
? string.Join(";", fallbackSearchPaths)
: null;

IList<String> pathsToReturn = ToolLocationHelper.GetPathToReferenceAssemblies(
frameworkmoniker.Identifier,
frameworkmoniker.Version.ToString(),
frameworkmoniker.Profile,
rootPath,
targetFrameworkFallbackSearchPaths);
rootPath?.Value,
fallbackSearchPathsJoined);

if (!SuppressNotFoundError)
{
Expand All @@ -267,6 +287,26 @@ private IList<String> GetPaths(string rootPath, string targetFrameworkFallbackSe
return pathsToReturn;
}

/// <summary>
/// Resolves each semicolon-separated fallback search path to absolute via TaskEnvironment.
/// </summary>
private IList<AbsolutePath> ResolveAbsoluteFallbackSearchPaths(string fallbackSearchPaths)
{
if (string.IsNullOrEmpty(fallbackSearchPaths))
{
return [];
}

string[] parts = fallbackSearchPaths.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries);
var result = new AbsolutePath[parts.Length];
for (int i = 0; i < parts.Length; i++)
{
result[i] = TaskEnvironment.GetAbsolutePath(parts[i]);
}

return result;
}

#endregion
}
}