Skip to content
Draft
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
81 changes: 81 additions & 0 deletions src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,87 @@ public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches()
"An arbitrary external toolsDirectory should produce a different handshake " +
"than the BuildEnvironmentHelper default, proving the mismatch scenario.");
}

/// <summary>
/// Regression test for MSB4216 caused by drive-letter casing differences on Windows. The
/// .NET Framework parent passes the SDK directory ($(NetCoreSdkRoot)) as the tools directory
/// while the child re-resolves the same directory from its own process path. On hosted CI
/// agents these can differ only by drive-letter casing ("D:\..." vs "d:\..."). Because the
/// salt is a case-sensitive hash they would mismatch, so the child accepts the parent's
/// drive-letter casing. This only widens what the child accepts, so it never rejects a parent
/// that connects successfully today.
/// </summary>
[WindowsOnlyFact]
public void Handshake_NetTaskHost_AcceptsDriveLetterCasingDifference()
{
HandshakeOptions options = GetNetTaskHostHandshakeOptions();

// The child resolved its own location with one drive-letter casing; the parent supplied
// the same directory with the opposite casing.
var child = new Handshake(options, @"d:\agent\_work\sdk\11.0.100");
var parent = new Handshake(options, @"D:\agent\_work\sdk\11.0.100");

int childSalt = child.RetrieveHandshakeComponents().Salt;
int parentSalt = parent.RetrieveHandshakeComponents().Salt;

// The raw salts differ because the hash is case-sensitive...
parentSalt.ShouldNotBe(childSalt,
"Drive-letter casing differences must produce different case-sensitive salts; " +
"otherwise this test would not exercise the tolerance.");

// ...but the child tolerates the parent's drive-letter casing.
child.IsNetTaskHostSaltMatch(parentSalt).ShouldBeTrue(
"The .NET task host child must accept a parent that resolved the same SDK directory " +
"with a different drive-letter casing, otherwise the handshake fails with MSB4216.");
}

/// <summary>
/// The drive-letter tolerance must not weaken node-reuse isolation: a genuinely different
/// tools directory (not merely a drive-letter casing variant) must still be rejected.
/// </summary>
[WindowsOnlyFact]
public void Handshake_NetTaskHost_RejectsUnrelatedToolsDirectory()
{
HandshakeOptions options = GetNetTaskHostHandshakeOptions();

var child = new Handshake(options, @"d:\agent\_work\sdk\11.0.100");
var unrelated = new Handshake(options, @"d:\agent\_work\sdk\10.0.200");

child.IsNetTaskHostSaltMatch(unrelated.RetrieveHandshakeComponents().Salt).ShouldBeFalse(
"A different SDK directory is not a drive-letter casing variant and must not be accepted.");
}

/// <summary>
/// The drive-letter tolerance is scoped to the .NET task host. Other node types keep
/// requiring the exact salt, preserving existing node-reuse isolation.
/// </summary>
[WindowsOnlyFact]
public void Handshake_NonNetTaskHost_DoesNotWidenSaltAcceptance()
{
var handshake = new Handshake(HandshakeOptions.NodeReuse);

// Even the node's own salt must not be treated as a drive-letter variant match, because
// a non-.NET-task-host node never computes an alternate-casing salt.
handshake.IsNetTaskHostSaltMatch(handshake.RetrieveHandshakeComponents().Salt).ShouldBeFalse(
"Only the .NET task host computes an alternate-casing salt; other node types must " +
"not widen salt acceptance.");
}

private static HandshakeOptions GetNetTaskHostHandshakeOptions()
{
// Use explicit NET runtime and current architecture to ensure the NET HandshakeOptions
// flag is set, which is required for passing toolsDirectory to the Handshake constructor.
var netTaskHostParams = new TaskHostParameters(
runtime: XMakeAttributes.MSBuildRuntimeValues.net,
architecture: XMakeAttributes.GetCurrentMSBuildArchitecture(),
dotnetHostPath: null,
msBuildAssemblyPath: null);

return CommunicationsUtilities.GetHandshakeOptions(
taskHost: true,
taskHostParameters: netTaskHostParams,
nodeReuse: false);
}
#endif

/// <summary>
Expand Down
64 changes: 64 additions & 0 deletions src/Framework/BackEnd/Handshake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ internal class Handshake

protected readonly HandshakeComponents _handshakeComponents;

/// <summary>
/// For the .NET task host on Windows, the salt computed from the tools directory with the
/// opposite drive-letter casing (e.g. "d:\..." in addition to "D:\..."), or <see langword="null"/>
/// when not applicable. The parent and child derive the tools directory from different sources
/// (the parent from the <c>$(NetCoreSdkRoot)</c> property, the child from its own process path)
/// whose only realistic divergence is drive-letter casing; both spellings denote the same
/// directory. The child accepts this variant so it can connect to a parent that spelled the
/// drive differently. This only widens what the child accepts, so it never rejects a parent that
/// connects successfully today.
/// </summary>
private readonly int? _netTaskHostSaltDriveCaseVariant;

/// <summary>
/// Initializes a new instance of the <see cref="Handshake"/> class with the specified node type.
/// </summary>
Expand Down Expand Up @@ -82,6 +94,17 @@ protected Handshake(HandshakeOptions nodeType, bool includeSessionId, string? to

int salt = CommunicationsUtilities.GetHashCode($"{handshakeSalt}{toolsDirectory}");

// The .NET task host parent (.NET Framework MSBuild, e.g. Visual Studio) and child
// (.NET SDK MSBuild) compute this salt independently from different sources, and on Windows
// those can differ only by drive-letter casing ("D:\..." vs "d:\..."). Because the salt is a
// case-sensitive hash, that produces a mismatch and the otherwise-valid handshake fails with
// MSB4216. Precompute the salt for the alternate drive-letter casing so the child also accepts
// the parent's spelling (see IsNetTaskHostSaltMatch). Only the child consults this value.
if (IsNetTaskHost && TryGetDriveLetterCaseVariant(toolsDirectory, out string? alternateToolsDirectory))
{
_netTaskHostSaltDriveCaseVariant = CommunicationsUtilities.GetHashCode($"{handshakeSalt}{alternateToolsDirectory}");
}

CommunicationsUtilities.Trace($"Handshake salt is {handshakeSalt}");
CommunicationsUtilities.Trace($"Tools directory root is {toolsDirectory}");

Expand All @@ -105,6 +128,47 @@ protected Handshake(HandshakeOptions nodeType, bool includeSessionId, string? to
private bool IsNetTaskHost
=> IsHandshakeOptionEnabled(HandshakeOptions, HandshakeOptions.NET | HandshakeOptions.TaskHost);

/// <summary>
/// Determines whether <paramref name="receivedSalt"/> matches this node's salt computed for the
/// alternate drive-letter casing of its tools directory. Used only by the .NET task host child to
/// tolerate a parent that resolved the same SDK directory with different drive-letter casing
/// (e.g. "D:\..." vs "d:\..."), which would otherwise fail the case-sensitive handshake with
/// MSB4216. Returns <see langword="false"/> for every other node type and configuration, so the
/// exact salt remains required there.
/// </summary>
public bool IsNetTaskHostSaltMatch(int receivedSalt)
=> _netTaskHostSaltDriveCaseVariant is int alternateSalt
&& receivedSalt == CommunicationsUtilities.AvoidEndOfHandshakeSignal(alternateSalt);

/// <summary>
/// Produces <paramref name="path"/> with the drive letter switched to the opposite casing
/// (e.g. "D:\dir" -&gt; "d:\dir"). Returns <see langword="false"/> when there is nothing to flip:
/// off Windows, for empty paths, for paths without a drive letter (such as UNC paths), or when the
/// drive letter is not an ASCII letter.
/// </summary>
private static bool TryGetDriveLetterCaseVariant(string? path, out string? variant)
{
variant = null;

if (!NativeMethods.IsWindows || string.IsNullOrEmpty(path) || path!.Length < 2 || path[1] != ':')
{
return false;
}

char drive = path[0];
char flippedDrive = char.IsUpper(drive)
? char.ToLowerInvariant(drive)
: char.IsLower(drive) ? char.ToUpperInvariant(drive) : drive;

if (flippedDrive == drive)
{
return false;
}

variant = flippedDrive + path.Substring(1);
return true;
}

#if NETFRAMEWORK
private bool IsClr2TaskHost
=> IsHandshakeOptionEnabled(HandshakeOptions, HandshakeOptions.CLR2 | HandshakeOptions.TaskHost);
Expand Down
12 changes: 10 additions & 2 deletions src/Shared/NodeEndpointOutOfProcBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ private void PacketPumpProc()
CommunicationsUtilities.Trace($"Handshake failed with error: {result.ErrorMessage}");
}

if (!IsHandshakePartValid(component, result.Value, index))
if (!IsHandshakePartValid(component, result.Value, index, handshake))
{
CommunicationsUtilities.Trace(
$"Handshake failed. Received {result.Value} from host for {component.Key} but expected {component.Value}. Probably the host is a different MSBuild build.");
Expand Down Expand Up @@ -548,7 +548,7 @@ private void PacketPumpProc()
/// <summary>
/// Method to verify that the handshake part received from the host matches the expected values.
/// </summary>
private bool IsHandshakePartValid(KeyValuePair<string, int> component, int handshakePart, int index)
private bool IsHandshakePartValid(KeyValuePair<string, int> component, int handshakePart, int index, Handshake handshake)
{
if (handshakePart == component.Value)
{
Expand All @@ -565,6 +565,14 @@ private bool IsHandshakePartValid(KeyValuePair<string, int> component, int hands
// 0x00FFFFFF is the handshake version included in component, the rest is the node type.
isAllowedMismatch = IsAllowedBitnessMismatch(component.Value, handshakePart);
}
else if (component.Key == nameof(HandshakeComponents.Salt))
{
// The .NET task host parent and child derive the tools-directory portion of the salt
// from different sources that, on Windows, can differ only by drive-letter casing
// ("D:\..." vs "d:\..."). Both spellings denote the same directory, so accept the
// parent's casing rather than failing the handshake with MSB4216.
isAllowedMismatch = handshake.IsNetTaskHostSaltMatch(handshakePart);
}
else
{
isAllowedMismatch = _versionHandshakeGroup.Contains(component.Key) && component.Value == Handshake.NetTaskHostHandshakeVersion;
Expand Down
Loading