From 6c141b18f2aacf039671e6b97b1934a12902b9d5 Mon Sep 17 00:00:00 2001 From: Jan Kratochvil Date: Fri, 12 Jun 2026 18:25:52 +0200 Subject: [PATCH 1/2] Add failing test: ToAssetFileDictionary must group by path case-insensitively on Windows StaticWebAssetEndpoint.ToAssetFileDictionary builds its keying dictionary with the default ordinal comparer (StaticWebAssetEndpoint.cs:178). On Windows the filesystem is case-insensitive, but two endpoints whose AssetFile values differ only in casing (e.g. 'C:\Repo\WWWRoot\site.css' vs 'c:\repo\wwwroot\site.css') currently land in separate buckets. This makes downstream lookups such as ApplyCompressionNegotiation's endpointsByAsset.TryGetValue(compressedAsset.Identity, ...) silently miss when MSBuild round-trips path casing differently between producers (ResolveProjectReferences vs DefineStaticWebAssets, etc.), dropping the related-asset's endpoint from compression-negotiation processing. Sibling SWA dictionaries (GeneratePackageAssetsManifestFile.cs:92, UpdatePackageStaticWebAssets.cs:46) pass Utils.OSPath.PathComparer for exactly this reason. The test is Windows-only because OSPath.PathComparer is OrdinalIgnoreCase only on Windows; on Linux/macOS it is StringComparer.Ordinal (matching the case-sensitive filesystem), which is observationally identical to the default Dictionary comparer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...bAssetEndpointToAssetFileDictionaryTest.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointToAssetFileDictionaryTest.cs diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointToAssetFileDictionaryTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointToAssetFileDictionaryTest.cs new file mode 100644 index 000000000000..c02e62162cda --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointToAssetFileDictionaryTest.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.NET.TestFramework; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public class StaticWebAssetEndpointToAssetFileDictionaryTest +{ + // ToAssetFileDictionary keys are absolute filesystem paths (endpoint.AssetFile, typically + // populated by upstream targets via MSBuild's %(FullPath) modifier). On Windows the + // filesystem is case-insensitive, so two endpoints whose AssetFile values differ only in + // casing must collide into a single dictionary entry — otherwise downstream lookups such + // as ApplyCompressionNegotiation's `endpointsByAsset.TryGetValue(compressedAsset.Identity, ...)` + // can silently miss when MSBuild round-trips path casing differently between producers + // (e.g. ResolveProjectReferences vs DefineStaticWebAssets). + // + // This test is Windows-only because OSPath.PathComparer is OrdinalIgnoreCase only on + // Windows; on Linux/macOS it is StringComparer.Ordinal (matching the case-sensitive + // filesystem), which is observationally identical to the default Dictionary comparer. + [PlatformSpecificFact(TestPlatforms.Windows)] + public void GroupsEndpoints_ByPath_CaseInsensitively_OnWindows() + { + var upperCased = MakeEndpointItem("C:\\Repo\\WWWRoot\\site.css", "/_content/lib/site.css"); + var lowerCased = MakeEndpointItem("c:\\repo\\wwwroot\\site.css", "/_content/lib/site.css.gz"); + + var result = StaticWebAssetEndpoint.ToAssetFileDictionary([upperCased, lowerCased]); + + result.Should().HaveCount(1, + "two endpoints whose AssetFile values differ only in casing point to the same " + + "file on Windows and must share a single dictionary entry"); + var only = result.Values.Single(); + only.Should().HaveCount(2, + "both endpoints must be grouped under the case-insensitive key"); + } + + private static ITaskItem MakeEndpointItem(string assetFile, string route) => + new TaskItem(route, new Dictionary + { + [nameof(StaticWebAssetEndpoint.AssetFile)] = assetFile, + [nameof(StaticWebAssetEndpoint.Selectors)] = "", + [nameof(StaticWebAssetEndpoint.ResponseHeaders)] = "", + [nameof(StaticWebAssetEndpoint.EndpointProperties)] = "", + }); +} From 004e4ab69ce0c5a3bce7d7c9a484ea3f79c58f34 Mon Sep 17 00:00:00 2001 From: Jan Kratochvil Date: Fri, 12 Jun 2026 18:26:28 +0200 Subject: [PATCH 2/2] Use OSPath.PathComparer in ToAssetFileDictionary Fixes the previously failing GroupsEndpoints_ByPath_CaseInsensitively_OnWindows test by passing Utils.OSPath.PathComparer (OrdinalIgnoreCase on Windows, Ordinal on Linux/macOS) to the keying dictionary so AssetFile path lookups match the OS filesystem semantics, consistent with sibling SWA dictionaries (GeneratePackageAssetsManifestFile.cs:92, UpdatePackageStaticWebAssets.cs:46). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs index b2fee114510b..4ffc22eb31cb 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs @@ -7,6 +7,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -175,7 +176,7 @@ internal void MarkProperiesAsModified() internal static IDictionary> ToAssetFileDictionary(ITaskItem[] candidateEndpoints) { - var result = new Dictionary>(candidateEndpoints.Length / 2); + var result = new Dictionary>(candidateEndpoints.Length / 2, OSPath.PathComparer); foreach (var candidate in candidateEndpoints) {