From a00475d3dc7b30742352722281e7e9e3f958a9bd Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:49:56 +0200 Subject: [PATCH 01/12] Add workaround for SDK root drive casing issue --- .../tools/Workarounds.targets | 200 +++++++++++++++++- 1 file changed, 199 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index b45e3018635..f296b9682c9 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -1,5 +1,5 @@ - + + + + + + + + + + = 2 && canonical[1] == ':') + { + char canonicalDrive = canonical[0]; + if (canonicalDrive != SdkRoot[0]) + { + Result = canonicalDrive + SdkRoot.Substring(1); + Log.LogMessage( + MessageImportance.Low, + "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match Environment.ProcessPath (#14026 workaround).", + SdkRoot[0], + canonicalDrive); + } + } + } + finally + { + FreeLibrary(module); + } + } + catch (Exception ex) + { + // Swallow: a casing mismatch is the only thing we're trying to fix, and + // we must not regress builds where this best-effort probe cannot run. + Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing skipped: {0}", ex.Message); + } + + return true; + } +} +]]> + + + + + + + + + From c7e87d6bfd8902ddd908715db301c2bbd8c8e129 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:01:59 +0200 Subject: [PATCH 02/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index f296b9682c9..3ec0e30a9d3 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -258,7 +258,7 @@ + Condition="'$(OS)' == 'Windows_NT' and '$(MSBuildRuntimeType)' == 'Full'"> From d42487d44fa8d0428e9b3288b40753f6a5f711c4 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:09 +0200 Subject: [PATCH 03/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 3ec0e30a9d3..b0e0dac6690 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -376,8 +376,7 @@ public class NormalizeSdkRootDriveCasing : Task - + Condition="'$(OS)' == 'Windows_NT' and '$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From 18703249772ed8704aa81c2c08686ecaee056ae3 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:23 +0200 Subject: [PATCH 04/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index b0e0dac6690..9f1c4869c01 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -243,15 +243,13 @@ Wiring ------ - Import this file from a Directory.Build.props (or Directory.Build.targets) - so it is imported into every project: + This workaround is implemented in Workarounds.targets and is imported automatically + by Microsoft.DotNet.Arcade.Sdk (see sdk/Sdk.targets). No additional wiring is + required for Arcade SDK consumers. - - - InitialTargets on this aggregates into the importing project's - InitialTargets, so the target runs once per project BEFORE any other target - (and therefore before the first .NET task host is launched). NetCoreSdkRoot - is defined during evaluation, so it is available by the time the target runs. + InitialTargets on this aggregates into the importing project's InitialTargets, + so the target runs early in the build (before the first .NET task host is launched). + NetCoreSdkRoot is defined during evaluation, so it is available by the time the target runs. ========================================================================== --> From 7fd82d0ace98087de8de9a8ea354efecefbfeacb Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:13 +0200 Subject: [PATCH 05/12] Update conditions for NormalizeSdkRootDriveCasing task --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 9f1c4869c01..85a10d606b6 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -256,7 +256,7 @@ + Condition="'$(MSBuildRuntimeType)' == 'Full' and and '$(NetCoreSdkRoot)' != ''"> @@ -374,7 +374,7 @@ public class NormalizeSdkRootDriveCasing : Task + Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From ae6747d75d0befbbd8395981f12e3a86018b665b Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:32 +0200 Subject: [PATCH 06/12] Fix condition syntax in Workarounds.targets --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 85a10d606b6..e99150ea26f 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -256,7 +256,7 @@ + Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From 57ae827dc245b35140d936ee5822ece78e01fe53 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:04:36 +0200 Subject: [PATCH 07/12] Update Workarounds.targets --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index e99150ea26f..da0d833eedc 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -375,6 +375,7 @@ public class NormalizeSdkRootDriveCasing : Task + From dd1aa62f99790f2e3ff7024397b7309058e47aec Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:41:56 +0200 Subject: [PATCH 08/12] Use CreateFile+GetFinalPathNameByHandle for SDK root drive casing The previous LoadLibraryEx(LOAD_LIBRARY_AS_DATAFILE)+GetModuleFileName(hModule) technique was a silent no-op: GetModuleFileName returns ERROR_MOD_NOT_FOUND for an image loaded only as a data file (the SDK's MSBuild.exe/MSBuild.dll are not otherwise loaded in the VS MSBuild process), so the casing was never fixed and MSB4216 persisted (verified in build 2998220). Open the SDK root directory with CreateFileW(FILE_FLAG_BACKUP_SEMANTICS) and ask the OS for its canonical path via GetFinalPathNameByHandleW. This is load- and bitness-independent. Splice only the canonical drive letter, and only when the rest of the path is otherwise case-insensitively identical, so symlink/junction resolution (which GetFinalPathNameByHandle performs but the child's Environment.ProcessPath does not) cannot desync the two salts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 131 +++++++++++------- 1 file changed, 83 insertions(+), 48 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index da0d833eedc..a3530bee288 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -220,23 +220,32 @@ HOW WE MATCH Environment.ProcessPath EXACTLY -------------------------------------------- - Environment.ProcessPath is implemented as GetModuleFileNameW(NULL) for the - current process. We obtain the identical canonicalization in-process, WITHOUT - launching the SDK, by: - - LoadLibraryEx(, LOAD_LIBRARY_AS_DATAFILE) - GetModuleFileNameW(hModule) - - Empirically (verified on Windows), GetModuleFileNameW IGNORES the drive - casing passed to LoadLibraryEx and returns the volume's canonical casing - - the exact same value Environment.ProcessPath yields for a process launched - from that volume. The canonical drive-letter casing is a property of the - volume mount, not of the individual file, so loading MSBuild.exe (or, if the - apphost is absent, MSBuild.dll) from $(NetCoreSdkRoot) yields the same drive - letter the child task host will see. - - This is Windows-only, idempotent, and a safe no-op when no loadable image is - found under $(NetCoreSdkRoot). + Environment.ProcessPath is GetModuleFileNameW(NULL), which reports the + volume's canonical on-disk drive-letter casing. We obtain the identical + drive-letter casing in-process, WITHOUT launching the SDK, by opening + $(NetCoreSdkRoot) and asking the OS for its final (canonical) path: + + CreateFileW(, FILE_FLAG_BACKUP_SEMANTICS) // open the dir + GetFinalPathNameByHandleW(handle, VOLUME_NAME_DOS) // canonical path + + The canonical drive-letter casing is a property of the volume mount, not of + the individual file, so the drive letter returned for the SDK root directory + is the exact same drive letter the child task host will see via + Environment.ProcessPath. We splice ONLY that drive letter onto the original + path, and only when the rest of the canonical path is otherwise identical + (case-insensitively) to $(NetCoreSdkRoot). That guard rejects any change + introduced by symlink/junction resolution (which GetFinalPathNameByHandle + performs but GetModuleFileNameW does not), keeping the two sides in agreement. + + NOTE: an earlier revision used LoadLibraryEx(LOAD_LIBRARY_AS_DATAFILE) + + GetModuleFileNameW(hModule). That is unreliable: GetModuleFileNameW returns + ERROR_MOD_NOT_FOUND (0) for an image loaded only as a data file (e.g. the + SDK's MSBuild.exe/MSBuild.dll, which are not otherwise loaded in the VS + MSBuild process), making the workaround a silent no-op. CreateFile + + GetFinalPathNameByHandle is load- and bitness-independent. + + This is Windows-only, idempotent, and a safe no-op when $(NetCoreSdkRoot) + cannot be opened. Remove this workaround once BOTH the VS MSBuild host and the .NET SDK task host carry the bilateral salt-casing normalization from #14026. @@ -264,7 +273,6 @@ sb.Capacity) + { + sb = new StringBuilder((int)len); + len = GetFinalPathNameByHandleW(handle, sb, (uint)sb.Capacity, VOLUME_NAME_DOS); + if (len == 0) + { + return true; + } + } - // GetModuleFileNameW returns the same canonical path Environment.ProcessPath - // would report. Splice ONLY its drive letter onto the original SDK root. + // Strip the extended-length prefix (\\?\ or \\?\UNC\) that + // GetFinalPathNameByHandle prepends. string canonical = sb.ToString(); - if (canonical.Length >= 2 && canonical[1] == ':') + if (canonical.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) + { + // UNC path: no drive letter to splice, leave unchanged. + return true; + } + if (canonical.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + canonical = canonical.Substring(4); + } + + // Only adopt the canonical drive letter when the rest of the path is + // otherwise identical (case-insensitively). This rejects any structural + // change from symlink/junction resolution, which GetFinalPathNameByHandle + // performs but the child's Environment.ProcessPath (GetModuleFileNameW) + // does not - keeping both sides' salts in agreement. + if (canonical.Length >= 2 && canonical[1] == ':' && + string.Equals(canonical, SdkRoot, StringComparison.OrdinalIgnoreCase)) { char canonicalDrive = canonical[0]; if (canonicalDrive != SdkRoot[0]) @@ -356,7 +391,7 @@ public class NormalizeSdkRootDriveCasing : Task } finally { - FreeLibrary(module); + CloseHandle(handle); } } catch (Exception ex) From 427dadda576a0860c5def6013a0bf569ebb5daaf Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:21:08 +0200 Subject: [PATCH 09/12] Log NetCoreSdkRoot casing normalization at High importance Make the temporary #14026 workaround leave visible evidence in CI console logs (it only fires when the drive casing actually differs, so this is not noisy on healthy machines). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index a3530bee288..042df6bc027 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -381,8 +381,9 @@ public class NormalizeSdkRootDriveCasing : Task if (canonicalDrive != SdkRoot[0]) { Result = canonicalDrive + SdkRoot.Substring(1); + // High importance so the (temporary) workaround leaves clear evidence in CI logs. Log.LogMessage( - MessageImportance.Low, + MessageImportance.High, "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match Environment.ProcessPath (#14026 workaround).", SdkRoot[0], canonicalDrive); From 21f1eaf402a59208fda751ddcec1991d17ea704f Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:49:06 +0200 Subject: [PATCH 10/12] Fix NetCoreSdkRoot casing guard to ignore trailing separators NetCoreSdkRoot carries a trailing directory separator, but GetFinalPathNameByHandle returns the canonical path without one. The strict full-path equality guard therefore always failed, making the drive-casing normalization a silent no-op and leaving MSB4216 (#14026) unfixed. Compare with trailing separators trimmed from both sides. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 042df6bc027..5a6f851dcbd 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -374,8 +374,13 @@ public class NormalizeSdkRootDriveCasing : Task // change from symlink/junction resolution, which GetFinalPathNameByHandle // performs but the child's Environment.ProcessPath (GetModuleFileNameW) // does not - keeping both sides' salts in agreement. + // NetCoreSdkRoot carries a trailing directory separator, but + // GetFinalPathNameByHandle returns the path without one, so compare with + // trailing separators trimmed off both sides. + string canonicalTrimmed = canonical.TrimEnd('\\', '/'); + string sdkRootTrimmed = SdkRoot.TrimEnd('\\', '/'); if (canonical.Length >= 2 && canonical[1] == ':' && - string.Equals(canonical, SdkRoot, StringComparison.OrdinalIgnoreCase)) + string.Equals(canonicalTrimmed, sdkRootTrimmed, StringComparison.OrdinalIgnoreCase)) { char canonicalDrive = canonical[0]; if (canonicalDrive != SdkRoot[0]) From ca8b377c33f7a4e6547bd36b5506725ec8382923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Mon, 22 Jun 2026 13:58:37 +0200 Subject: [PATCH 11/12] Add deterministic override + disable knobs to SDK root drive-casing workaround The GetFinalPathNameByHandle probe is kept as the default, but its drive-letter casing cannot be proven correct on every agent (it may no-op where the loader and GetFinalPathNameByHandle disagree, or where the SDK path crosses a junction). Add two opt-in knobs so the #14026 workaround is robust and manually verifiable: * DisableNetCoreSdkRootDriveCasingWorkaround=true skips the target entirely. * NetCoreSdkRootDriveCasingOverride=lower|upper deterministically forces the drive-letter casing without touching the filesystem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 5a6f851dcbd..a8ea5ea47d4 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -247,6 +247,21 @@ This is Windows-only, idempotent, and a safe no-op when $(NetCoreSdkRoot) cannot be opened. + Knobs + ----- + * $(DisableNetCoreSdkRootDriveCasingWorkaround)=true + Fully disables this workaround (the target is skipped). + + * $(NetCoreSdkRootDriveCasingOverride)=lower | upper + Deterministic escape hatch. Forces the $(NetCoreSdkRoot) drive letter to + the requested casing WITHOUT probing the filesystem, bypassing the + GetFinalPathNameByHandle canonical-casing probe entirely. Use this when + the probe cannot reproduce the child's casing (e.g. the SDK path crosses + a junction/reparse point, or an environment where GetFinalPathNameByHandle + and the loader disagree on drive-letter casing), or to validate the fix + deterministically. Both this property and an environment variable of the + same name work (MSBuild surfaces environment variables as properties). + Remove this workaround once BOTH the VS MSBuild host and the .NET SDK task host carry the bilateral salt-casing normalization from #14026. @@ -268,6 +283,7 @@ Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> + @@ -283,6 +299,8 @@ public class NormalizeSdkRootDriveCasing : Task [Required] public string SdkRoot { get; set; } + public string Override { get; set; } + [Output] public string Result { get; set; } @@ -322,6 +340,38 @@ public class NormalizeSdkRootDriveCasing : Task return true; } + // Deterministic escape hatch: $(NetCoreSdkRootDriveCasingOverride) = "lower" | "upper" + // forces the drive-letter casing without probing the filesystem, bypassing the + // GetFinalPathNameByHandle canonical-casing probe entirely. This unblocks environments + // where the probe cannot reproduce the child's casing (e.g. the SDK path crosses a + // junction, or GetFinalPathNameByHandle and the loader disagree on drive-letter casing) + // and provides a deterministic way to validate the workaround. + if (!string.IsNullOrEmpty(Override)) + { + char forced = SdkRoot[0]; + if (string.Equals(Override, "lower", StringComparison.OrdinalIgnoreCase)) + { + forced = char.ToLowerInvariant(SdkRoot[0]); + } + else if (string.Equals(Override, "upper", StringComparison.OrdinalIgnoreCase)) + { + forced = char.ToUpperInvariant(SdkRoot[0]); + } + + if (forced != SdkRoot[0]) + { + Result = forced + SdkRoot.Substring(1); + Log.LogMessage( + MessageImportance.High, + "Forced NetCoreSdkRoot drive casing '{0}' -> '{1}' via NetCoreSdkRootDriveCasingOverride='{2}' (#14026 workaround).", + SdkRoot[0], + forced, + Override); + } + + return true; + } + // Open the SDK root directory (FILE_FLAG_BACKUP_SEMANTICS is required to get a // handle to a directory). We need no access rights for GetFinalPathNameByHandle. IntPtr handle = CreateFileW( @@ -415,8 +465,8 @@ public class NormalizeSdkRootDriveCasing : Task - + Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != '' and '$(DisableNetCoreSdkRootDriveCasingWorkaround)' != 'true'"> + From ba0ad56b0080639ad333f2b5768a1183bba2f3f6 Mon Sep 17 00:00:00 2001 From: Ales Prokop Date: Tue, 23 Jun 2026 12:10:05 +0200 Subject: [PATCH 12/12] Use dotnet-host probe for #14026 SDK root drive-casing workaround Replace the GetFinalPathNameByHandle probe (which returns an uppercase drive on the affected ADO agents and was a proven no-op) with one that launches the SDK's dotnet host and reads GetModuleFileNameW(NULL) - the exact API behind the .NET task host child's Environment.ProcessPath - so the host learns and adopts the child's drive-letter casing. Casing-only guard; cached; override/disable knobs. Validated green on a failing ADO CodeQL agent (microsoft-testfx): probe resolved 'd:', NetCoreSdkRoot rewritten D:->d:, MSB4216 eliminated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 386 ++++++++++-------- 1 file changed, 218 insertions(+), 168 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index a8ea5ea47d4..1165530d8f1 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -185,96 +185,74 @@ s_driveCache = new Dictionary(); public override bool Execute() { @@ -334,18 +298,13 @@ public class NormalizeSdkRootDriveCasing : Task try { - // Only handle local "X:\..." drive paths. if (string.IsNullOrEmpty(SdkRoot) || SdkRoot.Length < 2 || SdkRoot[1] != ':') { return true; } - // Deterministic escape hatch: $(NetCoreSdkRootDriveCasingOverride) = "lower" | "upper" - // forces the drive-letter casing without probing the filesystem, bypassing the - // GetFinalPathNameByHandle canonical-casing probe entirely. This unblocks environments - // where the probe cannot reproduce the child's casing (e.g. the SDK path crosses a - // junction, or GetFinalPathNameByHandle and the loader disagree on drive-letter casing) - // and provides a deterministic way to validate the workaround. + // Deterministic escape hatch: the NetCoreSdkRootDriveCasingOverride property = "lower" or + // "upper" forces the drive-letter casing without probing the SDK, for emergencies or testing. if (!string.IsNullOrEmpty(Override)) { char forced = SdkRoot[0]; @@ -361,103 +320,193 @@ public class NormalizeSdkRootDriveCasing : Task if (forced != SdkRoot[0]) { Result = forced + SdkRoot.Substring(1); - Log.LogMessage( - MessageImportance.High, + Log.LogMessage(MessageImportance.High, "Forced NetCoreSdkRoot drive casing '{0}' -> '{1}' via NetCoreSdkRootDriveCasingOverride='{2}' (#14026 workaround).", - SdkRoot[0], - forced, - Override); + SdkRoot[0], forced, Override); } return true; } - // Open the SDK root directory (FILE_FLAG_BACKUP_SEMANTICS is required to get a - // handle to a directory). We need no access rights for GetFinalPathNameByHandle. - IntPtr handle = CreateFileW( - SdkRoot, - 0, - FILE_SHARE_READ_WRITE_DELETE, - IntPtr.Zero, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - IntPtr.Zero); - - if (handle == INVALID_HANDLE_VALUE) + char registeredDrive = GetRegisteredSdkDrive(SdkRoot); + // Casing-only guard: only adopt the probed drive when it is the SAME letter as the SDK's + // (differing solely in case). This can never change the actual drive, only its casing. + if (registeredDrive != '\0' && + registeredDrive != SdkRoot[0] && + char.ToUpperInvariant(registeredDrive) == char.ToUpperInvariant(SdkRoot[0])) { - return true; + Result = registeredDrive + SdkRoot.Substring(1); + Log.LogMessage(MessageImportance.High, + "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match the SDK task host (#14026 workaround).", + SdkRoot[0], registeredDrive); } + } + catch (Exception ex) + { + Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing skipped: {0}", ex.Message); + } - try + return true; + } + + // Returns the volume-registered drive letter for the SDK, derived the same way the .NET task host + // child does: by launching the SDK's dotnet host and reading the drive-letter casing + // GetModuleFileNameW(NULL) - the API behind Environment.ProcessPath - resolves for that process. + // Returns '\0' if it cannot be determined. + private char GetRegisteredSdkDrive(string sdkRoot) + { + char driveKey = sdkRoot[0]; + lock (s_lock) + { + if (s_driveCache.TryGetValue(driveKey, out char cached)) { - var sb = new StringBuilder(1024); - uint len = GetFinalPathNameByHandleW(handle, sb, (uint)sb.Capacity, VOLUME_NAME_DOS); - if (len == 0) - { - return true; - } - if (len > sb.Capacity) - { - sb = new StringBuilder((int)len); - len = GetFinalPathNameByHandleW(handle, sb, (uint)sb.Capacity, VOLUME_NAME_DOS); - if (len == 0) - { - return true; - } - } + return cached; + } + + char probed = ProbeSdkDrive(sdkRoot); + s_driveCache[driveKey] = probed; + return probed; + } + } - // Strip the extended-length prefix (\\?\ or \\?\UNC\) that - // GetFinalPathNameByHandle prepends. - string canonical = sb.ToString(); - if (canonical.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) + // Finds the dotnet host (dotnet.exe) ON THE SAME VOLUME as the SDK, so its drive-letter casing + // matches what the task host child will report. Prefers the dotnet root derived from the SDK + // directory (\sdk\\ -> \dotnet.exe), then DOTNET_HOST_PATH but only + // if it is on the same drive letter as the SDK. Returns null if none exists. + private static string LocateDotnetHost(string sdkRoot) + { + try + { + // sdkRoot = \sdk\\ -> up two directories = . + string sdkVersionDir = sdkRoot.TrimEnd('\\', '/'); + string sdkDir = Path.GetDirectoryName(sdkVersionDir); + string dotnetRoot = Path.GetDirectoryName(sdkDir); + if (!string.IsNullOrEmpty(dotnetRoot)) + { + string candidate = Path.Combine(dotnetRoot, "dotnet.exe"); + if (File.Exists(candidate)) { - // UNC path: no drive letter to splice, leave unchanged. - return true; + return candidate; } - if (canonical.StartsWith(@"\\?\", StringComparison.Ordinal)) + } + } + catch + { + // fall through to DOTNET_HOST_PATH + } + + string fromEnv = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (!string.IsNullOrEmpty(fromEnv) && fromEnv.Length >= 2 && fromEnv[1] == ':' && + char.ToUpperInvariant(fromEnv[0]) == char.ToUpperInvariant(sdkRoot[0]) && + File.Exists(fromEnv) && + fromEnv.EndsWith("dotnet.exe", StringComparison.OrdinalIgnoreCase)) + { + return fromEnv; + } + + return null; + } + + private char ProbeSdkDrive(string sdkRoot) + { + // Launch the dotnet host (NOT the SDK's MSBuild.exe apphost, which needs a matching runtime + // that may not be resolvable in this process's environment). dotnet.exe is the runtime host + // itself, lives on the same volume as the SDK, and always launches; its own process-path + // drive-letter casing (GetModuleFileNameW) therefore equals the casing the .NET task host + // child will report for the SDK directory. + string dotnetExe = LocateDotnetHost(sdkRoot); + if (dotnetExe == null) + { + Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing: dotnet host not found for SDK '{0}'.", sdkRoot); + return '\0'; + } + + string tasksDll = Path.Combine(sdkRoot, "Microsoft.Build.Tasks.Core.dll"); + string tempDir = Path.Combine(Path.GetTempPath(), "sdkdrvprobe_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + string proj = Path.Combine(tempDir, "probe.proj"); + + // Bare (no Sdk attribute / no imports) so it does NOT re-import Arcade and cannot + // recurse into this workaround. The inline task P/Invokes GetModuleFileNameW(NULL) - the exact + // API behind the child task host's Environment.ProcessPath - so we read the same drive-letter + // casing the child will. No CDATA in the inner task code (it would close the outer CDATA). + var probe = new StringBuilder(); + probe.Append(""); + probe.Append(""); + probe.Append("\n"); + probe.Append("using System;\n"); + probe.Append("using System.Runtime.InteropServices;\n"); + probe.Append("using System.Text;\n"); + probe.Append("using Microsoft.Build.Framework;\n"); + probe.Append("using Microsoft.Build.Utilities;\n"); + probe.Append("public class PrintProcPath : Task {\n"); + probe.Append(" [DllImport(\"kernel32.dll\", CharSet = CharSet.Unicode, SetLastError = true)]\n"); + probe.Append(" static extern uint GetModuleFileNameW(IntPtr hModule, StringBuilder lpFilename, uint nSize);\n"); + probe.Append(" public override bool Execute() {\n"); + probe.Append(" var sb = new StringBuilder(1024);\n"); + probe.Append(" GetModuleFileNameW(IntPtr.Zero, sb, 1024u);\n"); + probe.Append(" Log.LogMessage(MessageImportance.High, \"").Append(Marker).Append("\" + sb.ToString());\n"); + probe.Append(" return true;\n"); + probe.Append(" }\n"); + probe.Append("}\n"); + probe.Append(""); + probe.Append(""); + probe.Append(""); + File.WriteAllText(proj, probe.ToString()); + + try + { + var psi = new ProcessStartInfo + { + FileName = dotnetExe, + Arguments = "msbuild \"" + proj + "\" -nologo -nodeReuse:false -v:m -t:P", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = tempDir, + }; + + using (var p = Process.Start(psi)) + { + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + if (!p.WaitForExit(120000)) { - canonical = canonical.Substring(4); + try { p.Kill(); } catch { } + Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing: probe timed out launching '{0}'.", dotnetExe); + return '\0'; } - // Only adopt the canonical drive letter when the rest of the path is - // otherwise identical (case-insensitively). This rejects any structural - // change from symlink/junction resolution, which GetFinalPathNameByHandle - // performs but the child's Environment.ProcessPath (GetModuleFileNameW) - // does not - keeping both sides' salts in agreement. - // NetCoreSdkRoot carries a trailing directory separator, but - // GetFinalPathNameByHandle returns the path without one, so compare with - // trailing separators trimmed off both sides. - string canonicalTrimmed = canonical.TrimEnd('\\', '/'); - string sdkRootTrimmed = SdkRoot.TrimEnd('\\', '/'); - if (canonical.Length >= 2 && canonical[1] == ':' && - string.Equals(canonicalTrimmed, sdkRootTrimmed, StringComparison.OrdinalIgnoreCase)) + int idx = stdout.IndexOf(Marker, StringComparison.Ordinal); + if (idx >= 0) { - char canonicalDrive = canonical[0]; - if (canonicalDrive != SdkRoot[0]) + string resolved = stdout.Substring(idx + Marker.Length).TrimStart(); + int nl = resolved.IndexOfAny(new[] { '\r', '\n' }); + if (nl >= 0) { - Result = canonicalDrive + SdkRoot.Substring(1); - // High importance so the (temporary) workaround leaves clear evidence in CI logs. - Log.LogMessage( - MessageImportance.High, - "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match Environment.ProcessPath (#14026 workaround).", - SdkRoot[0], - canonicalDrive); + resolved = resolved.Substring(0, nl); + } + + if (resolved.Length >= 2 && resolved[1] == ':') + { + return resolved[0]; } } - } - finally - { - CloseHandle(handle); + else + { + Log.LogMessage(MessageImportance.Low, + "NormalizeSdkRootDriveCasing: probe produced no result (exit {0}). stderr=[{1}]", + p.ExitCode, stderr.Replace("\r", "").Replace("\n", " | ")); + } } } - catch (Exception ex) + finally { - // Swallow: a casing mismatch is the only thing we're trying to fix, and - // we must not regress builds where this best-effort probe cannot run. - Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing skipped: {0}", ex.Message); + try { Directory.Delete(tempDir, recursive: true); } catch { } } - return true; + return '\0'; } } ]]> @@ -472,3 +521,4 @@ public class NormalizeSdkRootDriveCasing : Task +