Skip to content

Commit 6fcf1c0

Browse files
committed
Fixes
1 parent 508948d commit 6fcf1c0

File tree

5 files changed

+286
-20
lines changed

5 files changed

+286
-20
lines changed

src/StaticWebAssetsSdk/Tasks/Data/ContentTypeProvider.cs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal class ContentTypeProvider
2828
["*.jsx"] = new ContentTypeMapping("text/jscript", null, "*.jsx", 1),
2929
["*.markdown"] = new ContentTypeMapping("text/markdown", null, "*.markdown", 1),
3030
["*.gz"] = new ContentTypeMapping("application/x-gzip", null, "*.gz", 1),
31+
["*.br"] = new ContentTypeMapping("application/octet-stream", null, "*.br", 1),
3132
["*.md"] = new ContentTypeMapping("text/markdown", null, "*.md", 1),
3233
["*.bmp"] = new ContentTypeMapping("image/bmp", null, "*.bmp", 1),
3334
["*.jpeg"] = new ContentTypeMapping("image/jpeg", null, "*.jpeg", 1),
@@ -423,14 +424,21 @@ public ContentTypeProvider(ContentTypeMapping[] customMappings)
423424

424425
internal ContentTypeMapping ResolveContentTypeMapping(string relativePath, TaskLoggingHelper log)
425426
{
426-
var (resolvePathWithoutCompressedExtension, hasCompressedExtension) = ResolvePathWithoutCompressedExtension(relativePath);
427+
#if NET9_0_OR_GREATER
428+
var fileNameSpan = Path.GetFileName(relativePath.AsSpan());
429+
var fileName = relativePath.AsMemory().Slice(relativePath.Length - fileNameSpan.Length);
430+
#else
431+
var fileName = Path.GetFileName(relativePath);
432+
#endif
433+
var (resolvePathWithoutCompressedExtension, hasCompressedExtension) = ResolvePathWithoutCompressedExtension(fileName);
427434

428435
var match = _matcher.Match(resolvePathWithoutCompressedExtension);
429436
if (match.IsMatch)
430437
{
431438
if (_builtInMappings.TryGetValue(match.Pattern, out var mapping) || _customMappings.TryGetValue(match.Pattern, out mapping))
432439
{
433440
log.LogMessage(MessageImportance.Low, $"Matched {relativePath} to {mapping.MimeType} using pattern {match.Pattern}");
441+
return mapping;
434442
}
435443
else
436444
{
@@ -439,12 +447,13 @@ internal ContentTypeMapping ResolveContentTypeMapping(string relativePath, TaskL
439447
}
440448
else if (hasCompressedExtension)
441449
{
442-
match = _matcher.Match(relativePath);
450+
match = _matcher.Match(fileName);
443451
if (match.IsMatch)
444452
{
445453
if (_builtInMappings.TryGetValue(match.Pattern, out var mapping) || _customMappings.TryGetValue(match.Pattern, out mapping))
446454
{
447455
log.LogMessage(MessageImportance.Low, $"Matched {relativePath} to {mapping.MimeType} using pattern {match.Pattern}");
456+
return mapping;
448457
}
449458
else
450459
{
@@ -457,36 +466,37 @@ internal ContentTypeMapping ResolveContentTypeMapping(string relativePath, TaskL
457466
}
458467

459468
#if NET9_0_OR_GREATER
460-
private (ReadOnlyMemory<char> relativePath, bool hasCompressedExtension) ResolvePathWithoutCompressedExtension(string relativePath)
469+
private (ReadOnlyMemory<char> relativePath, bool hasCompressedExtension) ResolvePathWithoutCompressedExtension(ReadOnlyMemory<char> fileName)
461470
{
462-
var memory = relativePath.AsMemory();
463-
var extension = Path.GetExtension(memory.Span);
471+
var extension = Path.GetExtension(fileName.Span);
464472
bool hasCompressedExtension = extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) || extension.Equals(".br", StringComparison.OrdinalIgnoreCase);
465473
if (hasCompressedExtension)
466474
{
467-
var fileName = Path.GetFileNameWithoutExtension(memory.Span);
468-
if (!Path.GetExtension(fileName).Equals("", StringComparison.Ordinal))
475+
var candidate = fileName.Slice(fileName.Length - fileName.Length);
476+
var fileNameNoExtension = Path.GetFileNameWithoutExtension(fileName.Span);
477+
if (!Path.GetExtension(fileNameNoExtension).Equals("", StringComparison.Ordinal))
469478
{
470-
return (memory.Slice(0, fileName.Length), hasCompressedExtension);
479+
return (candidate.Slice(0, fileNameNoExtension.Length), hasCompressedExtension);
471480
}
472481
}
473482

474-
return (memory, hasCompressedExtension);
483+
return (fileName, hasCompressedExtension);
475484
}
476485
#else
477486
private (string relativePath, bool hasCompressedExtension) ResolvePathWithoutCompressedExtension(string relativePath)
478487
{
479-
var extension = Path.GetExtension(relativePath);
488+
var fileName = Path.GetFileName(relativePath);
489+
var extension = Path.GetExtension(fileName);
480490
var hasCompressedExtension = extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) || extension.Equals(".br", StringComparison.OrdinalIgnoreCase);
481491
if (hasCompressedExtension)
482492
{
483-
var fileName = Path.GetFileNameWithoutExtension(relativePath);
493+
fileName = Path.GetFileNameWithoutExtension(relativePath);
484494
if (!Path.GetExtension(fileName).Equals("", StringComparison.Ordinal))
485495
{
486496
return (fileName, hasCompressedExtension);
487497
}
488498
}
489-
return (relativePath, hasCompressedExtension);
499+
return (fileName, hasCompressedExtension);
490500
}
491501
#endif
492502
}

src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
<PackageReference Include="Microsoft.Css.Parser" />
4141
</ItemGroup>
4242

43+
<ItemGroup>
44+
<InternalsVisibleTo Include="Microsoft.NET.Sdk.Razor.Tests" />
45+
</ItemGroup>
46+
4347
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
4448
<PackageReference Include="System.Reflection.Metadata" VersionOverride="$(SystemReflectionMetadataToolsetPackageVersion)" />
4549
<PackageReference Include="System.Text.Json" VersionOverride="$(SystemTextJsonToolsetPackageVersion)" />
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections;
5+
using Microsoft.Build.Framework;
6+
using Microsoft.Build.Utilities;
7+
using Microsoft.NET.Sdk.StaticWebAssets.Tasks;
8+
using Moq;
9+
10+
namespace Microsoft.NET.Sdk.Razor.Tests.StaticWebAssets;
11+
12+
public class ContentTypeProviderTests
13+
{
14+
private readonly TaskLoggingHelper _log = new TestTaskLoggingHelper();
15+
16+
[Fact]
17+
public void GetContentType_ReturnsTextPlainForTextFiles()
18+
{
19+
// Arrange
20+
var provider = new ContentTypeProvider([]);
21+
22+
// Act
23+
var contentType = provider.ResolveContentTypeMapping("Fake-License.txt", _log);
24+
25+
// Assert
26+
Assert.Equal("text/plain", contentType.MimeType);
27+
}
28+
29+
[Fact]
30+
public void GetContentType_ReturnsMappingForRelativePath()
31+
{
32+
// Arrange
33+
var provider = new ContentTypeProvider([]);
34+
35+
// Act
36+
var contentType = provider.ResolveContentTypeMapping("Components/Pages/Counter.razor.js", _log);
37+
38+
// Assert
39+
Assert.Equal("text/javascript", contentType.MimeType);
40+
}
41+
// wwwroot\exampleJsInterop.js.gz
42+
43+
[Fact]
44+
public void GetContentType_ReturnsMappingForCompressedRelativePath()
45+
{
46+
// Arrange
47+
var provider = new ContentTypeProvider([]);
48+
49+
// Act
50+
var contentType = provider.ResolveContentTypeMapping("wwwroot/exampleJsInterop.js.gz", _log);
51+
52+
// Assert
53+
Assert.Equal("text/javascript", contentType.MimeType);
54+
}
55+
56+
[Fact]
57+
public void GetContentType_HandlesFingerprintedPaths()
58+
{
59+
// Arrange
60+
var provider = new ContentTypeProvider([]);
61+
// Act
62+
var contentType = provider.ResolveContentTypeMapping("_content\\RazorPackageLibraryDirectDependency\\RazorPackageLibraryDirectDependency#[.{fingerprint}].bundle.scp.css.gz", _log);
63+
// Assert
64+
Assert.Equal("text/css", contentType.MimeType);
65+
}
66+
67+
[Fact]
68+
public void GetContentType_ReturnsDefaultForUnknownMappings()
69+
{
70+
// Arrange
71+
var provider = new ContentTypeProvider([]);
72+
73+
// Act
74+
var contentType = provider.ResolveContentTypeMapping("something.unknown", _log);
75+
76+
// Assert
77+
Assert.Null(contentType.MimeType);
78+
}
79+
80+
[Theory]
81+
[InlineData("something.unknown.gz", "application/x-gzip")]
82+
[InlineData("something.unknown.br", "application/octet-stream")]
83+
public void GetContentType_ReturnsGzipOrBrotliForUnknownCompressedMappings(string path, string expectedMapping)
84+
{
85+
// Arrange
86+
var provider = new ContentTypeProvider([]);
87+
88+
// Act
89+
var contentType = provider.ResolveContentTypeMapping(path, _log);
90+
91+
// Assert
92+
Assert.Equal(expectedMapping, contentType.MimeType);
93+
}
94+
95+
[Theory]
96+
[InlineData("Fake-License.txt.gz")]
97+
[InlineData("Fake-License.txt.br")]
98+
public void GetContentType_ReturnsTextPlainForCompressedTextFiles(string path)
99+
{
100+
// Arrange
101+
var provider = new ContentTypeProvider([]);
102+
103+
// Act
104+
var contentType = provider.ResolveContentTypeMapping(path, _log);
105+
106+
// Assert
107+
Assert.Equal("text/plain", contentType.MimeType);
108+
}
109+
110+
private class TestTaskLoggingHelper : TaskLoggingHelper
111+
{
112+
public TestTaskLoggingHelper() : base(new TestTask())
113+
{
114+
}
115+
116+
private class TestTask : ITask
117+
{
118+
public IBuildEngine BuildEngine { get; set; } = new TestBuildEngine();
119+
public ITaskHost HostObject { get; set; } = new TestTaskHost();
120+
121+
public bool Execute() => true;
122+
}
123+
124+
private class TestBuildEngine : IBuildEngine
125+
{
126+
public bool ContinueOnError => true;
127+
128+
public int LineNumberOfTaskNode => 0;
129+
130+
public int ColumnNumberOfTaskNode => 0;
131+
132+
public string ProjectFileOfTaskNode => "test.csproj";
133+
134+
public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true;
135+
136+
public void LogCustomEvent(CustomBuildEventArgs e) { }
137+
public void LogErrorEvent(BuildErrorEventArgs e) { }
138+
public void LogMessageEvent(BuildMessageEventArgs e) { }
139+
public void LogWarningEvent(BuildWarningEventArgs e) { }
140+
}
141+
142+
private class TestTaskHost : ITaskHost
143+
{
144+
public object HostObject { get; set; } = new object();
145+
}
146+
}
147+
148+
}

test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/DefineStaticWebAssetEndpointsTest.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.Metrics;
5+
using System.Diagnostics;
46
using Microsoft.AspNetCore.StaticWebAssets.Tasks;
57
using Microsoft.Build.Framework;
68
using Microsoft.Build.Utilities;
79
using Microsoft.NET.Sdk.StaticWebAssets.Tasks;
10+
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
811
using Moq;
12+
using NuGet.Packaging.Core;
13+
using System.Net;
914

1015
namespace Microsoft.NET.Sdk.Razor.Tests;
1116

@@ -375,6 +380,110 @@ public void DoesNotDefineNewEndpointsWhenAnExistingEndpointAlreadyExists()
375380
endpoints.Should().BeEmpty();
376381
}
377382

383+
[Fact]
384+
public void ResolvesContentType_ForCompressedAssets()
385+
{
386+
var errorMessages = new List<string>();
387+
var buildEngine = new Mock<IBuildEngine>();
388+
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
389+
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
390+
391+
var lastWrite = new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc);
392+
393+
var task = new DefineStaticWebAssetEndpoints
394+
{
395+
BuildEngine = buildEngine.Object,
396+
CandidateAssets = [
397+
new TaskItem(
398+
Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "rdfmaxp4ta-43emfwee4b.gz"),
399+
new Dictionary<string, string>
400+
{
401+
["RelativePath"] = "_framework/dotnet.timezones.blat.gz",
402+
["BasePath"] = "/",
403+
["AssetMode"] = "All",
404+
["AssetKind"] = "Build",
405+
["SourceId"] = "BlazorWasmHosted60.Client",
406+
["CopyToOutputDirectory"] = "PreserveNewest",
407+
["Fingerprint"] = "3ji2l2o1xa",
408+
["RelatedAsset"] = Path.Combine(AppContext.BaseDirectory, "Client", "bin", "Debug", "net6.0", "wwwroot", "_framework", "dotnet.timezones.blat"),
409+
["ContentRoot"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed"),
410+
["SourceType"] = "Computed",
411+
["Integrity"] = "TwfyUDDMyF5dWUB2oRhrZaTk8sEa9o8ezAlKdxypsX4=",
412+
["AssetRole"] = "Alternative",
413+
["AssetTraitValue"] = "gzip",
414+
["AssetTraitName"] = "Content-Encoding",
415+
["OriginalItemSpec"] = Path.Combine("D:", "work", "dotnet-sdk", "artifacts", "tmp", "Release", "Publish60Host---0200F604", "Client", "bin", "Debug", "net6.0", "wwwroot", "_framework", "dotnet.timezones.blat"),
416+
["CopyToPublishDirectory"] = "Never"
417+
})
418+
],
419+
ExistingEndpoints = [],
420+
ContentTypeMappings = [],
421+
TestLengthResolver = asset => asset.EndsWith(".gz") ? 10 : throw new InvalidOperationException(),
422+
TestLastWriteResolver = asset => asset.EndsWith(".gz") ? lastWrite : throw new InvalidOperationException(),
423+
};
424+
425+
// Act
426+
var result = task.Execute();
427+
428+
// Assert
429+
result.Should().Be(true);
430+
var endpoints = StaticWebAssetEndpoint.FromItemGroup(task.Endpoints);
431+
endpoints.Length.Should().Be(1);
432+
var endpoint = endpoints[0];
433+
endpoint.ResponseHeaders.Should().ContainSingle(h => h.Name == "Content-Type" && h.Value == "application/x-gzip");
434+
}
435+
436+
[Fact]
437+
public void ResolvesContentType_ForFingerprintedAssets()
438+
{
439+
var errorMessages = new List<string>();
440+
var buildEngine = new Mock<IBuildEngine>();
441+
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
442+
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
443+
444+
var lastWrite = new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc);
445+
446+
var task = new DefineStaticWebAssetEndpoints
447+
{
448+
BuildEngine = buildEngine.Object,
449+
CandidateAssets = [
450+
new TaskItem(
451+
Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "rdfmaxp4ta-43emfwee4b.gz"),
452+
new Dictionary<string, string>
453+
{
454+
["RelativePath"] = "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css.gz",
455+
["BasePath"] = "_content/RazorPackageLibraryDirectDependency",
456+
["AssetMode"] = "Reference",
457+
["AssetKind"] = "All",
458+
["SourceId"] = "RazorPackageLibraryDirectDependency",
459+
["CopyToOutputDirectory"] = "Never",
460+
["Fingerprint"] = "olx7vzw7zz",
461+
["RelatedAsset"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css"),
462+
["ContentRoot"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed"),
463+
["SourceType"] = "Package",
464+
["Integrity"] = "JK/W3g5zqZGxAM7zbv/pJ3ngpJheT01SXQ+NofKgQcc=",
465+
["AssetRole"] = "Alternative",
466+
["AssetTraitValue"] = "gzip",
467+
["AssetTraitName"] = "Content-Encoding",
468+
["OriginalItemSpec"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css"),
469+
["CopyToPublishDirectory"] = "PreserveNewest"
470+
})
471+
],
472+
ExistingEndpoints = [],
473+
ContentTypeMappings = [],
474+
TestLengthResolver = asset => asset.EndsWith(".gz") ? 10 : throw new InvalidOperationException(),
475+
TestLastWriteResolver = asset => asset.EndsWith(".gz") ? lastWrite : throw new InvalidOperationException(),
476+
};
477+
478+
// Act
479+
var result = task.Execute();
480+
result.Should().Be(true);
481+
var endpoints = StaticWebAssetEndpoint.FromItemGroup(task.Endpoints);
482+
endpoints.Length.Should().Be(1);
483+
var endpoint = endpoints[0];
484+
endpoint.ResponseHeaders.Should().ContainSingle(h => h.Name == "Content-Type" && h.Value == "text/css");
485+
}
486+
378487
[Fact]
379488
public void Produces_TheExpectedEndpoint_ForExternalAssets()
380489
{

0 commit comments

Comments
 (0)